From 562b8995d766fd786dfea017cd71fee69776b72f Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Wed, 3 Sep 2025 16:22:05 -0400 Subject: [PATCH 01/22] feat: Add "r" and "o" keyboard shortcut while in interactive listen. --- pkg/proxy/proxy.go | 233 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 226 insertions(+), 7 deletions(-) diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index afff791..c40130f 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -11,13 +11,17 @@ import ( "net/http" "net/url" "os" + "os/exec" "os/signal" + "runtime" "strconv" "strings" + "sync" "syscall" "time" log "github.com/sirupsen/logrus" + "golang.org/x/term" "github.com/hookdeck/hookdeck-cli/pkg/ansi" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" @@ -60,6 +64,9 @@ type Proxy struct { connections []*hookdecksdk.Connection webSocketClient *websocket.Client connectionTimer *time.Timer + latestEventID string + terminalMutex sync.Mutex + rawModeState *term.State } func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { @@ -77,6 +84,103 @@ func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { return ctx } +// safePrintf temporarily disables raw mode, prints the message, then re-enables raw mode +func (p *Proxy) safePrintf(format string, args ...interface{}) { + p.terminalMutex.Lock() + defer p.terminalMutex.Unlock() + + // Temporarily restore normal terminal mode for printing + if p.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), p.rawModeState) + } + + // Print the message + fmt.Printf(format, args...) + + // Re-enable raw mode + if p.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } +} + +func (p *Proxy) startKeyboardListener(ctx context.Context) { + // Check if we're in a terminal + if !term.IsTerminal(int(os.Stdin.Fd())) { + return + } + + go func() { + // Enter raw mode once and keep it + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return + } + + // Store the raw mode state for use in safePrintf + p.rawModeState = oldState + + // Ensure we restore terminal state when this goroutine exits + defer func() { + p.terminalMutex.Lock() + term.Restore(int(os.Stdin.Fd()), oldState) + p.terminalMutex.Unlock() + }() + + // Create a buffered channel for reading stdin + inputCh := make(chan byte, 1) + + // Start a separate goroutine to read from stdin + go func() { + defer close(inputCh) + buf := make([]byte, 1) + for { + select { + case <-ctx.Done(): + return + default: + n, err := os.Stdin.Read(buf) + if err != nil || n != 1 { + return + } + select { + case inputCh <- buf[0]: + case <-ctx.Done(): + return + } + } + } + }() + + // Main loop to process keyboard input + for { + select { + case <-ctx.Done(): + return + case key, ok := <-inputCh: + if !ok { + return + } + + // Process the key + switch key { + case 0x72, 0x52: // 'r' or 'R' + p.retryLatestEvent() + case 0x6F, 0x4F: // 'o' or 'O' + p.openLatestEventURL() + case 0x03: // Ctrl+C + proc, _ := os.FindProcess(os.Getpid()) + proc.Signal(os.Interrupt) + return + case 0x71, 0x51: // 'q' or 'Q' + proc, _ := os.FindProcess(os.Getpid()) + proc.Signal(os.Interrupt) + return + } + } + } + }() +} + // Run manages the connection to Hookdeck. // The connection is established in phases: // - Create a new CLI session @@ -105,6 +209,9 @@ func (p *Proxy) Run(parentCtx context.Context) error { }).Debug("Ctrl+C received, cleaning up...") }) + // Start keyboard listener for Ctrl+R/Cmd+R shortcuts + p.startKeyboardListener(signalCtx) + s := ansi.StartNewSpinner("Getting ready...", p.cfg.Log.Out) session, err := p.createSession(signalCtx) @@ -136,11 +243,20 @@ func (p *Proxy) Run(parentCtx context.Context) error { // Monitor the websocket for connection and update the spinner appropriately. go func() { <-p.webSocketClient.Connected() - msg := "Ready! (^C to quit)" + msg := "Ready... āŒØļø [r] Retry latest • [o] Open latest • [q] Quit" if hasConnectedOnce { msg = "Reconnected!" } + // Stop the spinner and print the message safely + p.terminalMutex.Lock() + if p.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), p.rawModeState) + } ansi.StopSpinner(s, msg, p.cfg.Log.Out) + if p.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } + p.terminalMutex.Unlock() hasConnectedOnce = true }() @@ -244,12 +360,15 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { webhookEvent := msg.Attempt + // Store the latest event ID for retry/open functionality + p.latestEventID = webhookEvent.Body.EventID + p.cfg.Log.WithFields(log.Fields{ "prefix": "proxy.Proxy.processAttempt", }).Debugf("Processing webhook event") if p.cfg.PrintJSON { - fmt.Println(webhookEvent.Body.Request.DataString) + p.safePrintf("%s\n", webhookEvent.Body.Request.DataString) } else { url := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path tr := &http.Transport{ @@ -268,13 +387,13 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { req, err := http.NewRequest(webhookEvent.Body.Request.Method, url, nil) if err != nil { - fmt.Printf("Error: %s\n", err) + p.safePrintf("Error: %s\n", err) return } x := make(map[string]json.RawMessage) err = json.Unmarshal(webhookEvent.Body.Request.Headers, &x) if err != nil { - fmt.Printf("Error: %s\n", err) + p.safePrintf("Error: %s\n", err) return } @@ -299,7 +418,7 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { err, ) - fmt.Println(errStr) + p.safePrintf("%s\n", errStr) p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ Event: "attempt_response", @@ -317,7 +436,7 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *http.Response) { localTime := time.Now().Format(timeLayout) color := ansi.Color(os.Stdout) - var url = p.cfg.DashboardBaseURL + "/cli/events/" + webhookEvent.Body.EventID + var url = p.cfg.DashboardBaseURL + "/events/cli/" + webhookEvent.Body.EventID if p.cfg.ProjectMode == "console" { url = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID } @@ -328,7 +447,7 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h resp.Request.URL, url, ) - fmt.Println(outputStr) + p.safePrintf("%s\n", outputStr) buf, err := ioutil.ReadAll(resp.Body) if err != nil { @@ -356,6 +475,106 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h } } +func (p *Proxy) retryLatestEvent() { + eventID := p.latestEventID + + if eventID == "" { + color := ansi.Color(os.Stdout) + p.safePrintf("[%s] No event to retry\n", + color.Yellow("WARN"), + ) + return + } + + // Create HTTP client for retry request + parsedBaseURL, err := url.Parse(p.cfg.APIBaseURL) + if err != nil { + color := ansi.Color(os.Stdout) + p.safePrintf("[%s] Failed to parse API URL for retry: %v\n", + color.Red("ERROR"), + err, + ) + return + } + + client := &hookdeck.Client{ + BaseURL: parsedBaseURL, + APIKey: p.cfg.Key, + ProjectID: p.cfg.ProjectID, + } + + // Make retry request to Hookdeck API + retryURL := fmt.Sprintf("/events/%s/retry", eventID) + resp, err := client.Post(context.Background(), retryURL, []byte("{}"), nil) + if err != nil { + color := ansi.Color(os.Stdout) + p.safePrintf("[%s] Failed to retry event %s\n", + color.Red("ERROR"), + eventID, + ) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + color := ansi.Color(os.Stdout) + p.safePrintf("[%s] Failed to retry event %s\n", + color.Red("ERROR"), + eventID, + ) + } +} + +func (p *Proxy) openLatestEventURL() { + eventID := p.latestEventID + + if eventID == "" { + color := ansi.Color(os.Stdout) + p.safePrintf("[%s] No event to open\n", + color.Yellow("WARN"), + ) + return + } + + // Build event URL based on project mode + var eventURL string + if p.cfg.ProjectMode == "console" { + eventURL = p.cfg.ConsoleBaseURL + "/?event_id=" + eventID + } else { + eventURL = p.cfg.DashboardBaseURL + "/events/cli/" + eventID + } + + // Open URL in browser + err := p.openBrowser(eventURL) + if err != nil { + color := ansi.Color(os.Stdout) + p.safePrintf("[%s] Failed to open browser: %v\n", + color.Red("ERROR"), + err, + ) + return + } +} + +func (p *Proxy) openBrowser(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start", url} + case "darwin": + cmd = "open" + args = []string{url} + default: // "linux", "freebsd", "openbsd", "netbsd" + cmd = "xdg-open" + args = []string{url} + } + + return exec.Command(cmd, args...).Start() +} + // // Public functions // From 8188cb8c20877dbb4ea22ac342560f1c299aa125 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Wed, 3 Sep 2025 16:24:57 -0400 Subject: [PATCH 02/22] chore: Tweak console output for clarify --- pkg/listen/listen.go | 4 +--- pkg/listen/printer.go | 43 ++++++++++++++++++++++++++++++------------- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/pkg/listen/listen.go b/pkg/listen/listen.go index 2480a88..09a9041 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -121,9 +121,7 @@ Specify a single destination to update the path. For example, pass a connection fmt.Println() printDashboardInformation(config, guestURL) fmt.Println() - printSources(config, sources) - fmt.Println() - printConnections(config, connections) + printSourcesWithConnections(config, sources, connections, URL) fmt.Println() p := proxy.New(&proxy.Config{ diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index 265c0b9..ce7b297 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -2,6 +2,7 @@ package listen import ( "fmt" + "net/url" "github.com/hookdeck/hookdeck-cli/pkg/ansi" "github.com/hookdeck/hookdeck-cli/pkg/config" @@ -18,11 +19,11 @@ func printListenMessage(config *config.Config, isMultiSource bool) { } func printDashboardInformation(config *config.Config, guestURL string) { - fmt.Println(ansi.Bold("Dashboard")) + if guestURL != "" { - fmt.Println("šŸ‘¤ Console URL: " + guestURL) - fmt.Println("Sign up in the Console to make your webhook URL permanent.") + fmt.Printf("─ %s ──────────────────────────────────────────────────────\n", "Console") fmt.Println() + fmt.Println("šŸ‘‰ Sign up to make your webhook URL permanent: %s", guestURL) } else { var url = config.DashboardBaseURL if config.Profile.ProjectId != "" { @@ -31,21 +32,37 @@ func printDashboardInformation(config *config.Config, guestURL string) { if config.Profile.ProjectMode == "console" { url = config.ConsoleBaseURL } - fmt.Println("šŸ‘‰ Inspect and replay events: " + url) + fmt.Printf("─ %s ──────────────────────────────────────────────────────\n", "Dashboard") + fmt.Println() + fmt.Printf("šŸ‘‰ Inspect, retry & boomark events: %s\n", url) } } -func printSources(config *config.Config, sources []*hookdecksdk.Source) { - fmt.Println(ansi.Bold("Sources")) +func printSourcesWithConnections(config *config.Config, sources []*hookdecksdk.Source, connections []*hookdecksdk.Connection, targetURL *url.URL) { + // Group connections by source ID + sourceConnections := make(map[string][]*hookdecksdk.Connection) + for _, connection := range connections { + sourceID := connection.Source.Id + sourceConnections[sourceID] = append(sourceConnections[sourceID], connection) + } + + // Print the Sources title line + fmt.Printf("─ %s ───────────────────────────────────────────────────\n", "Listening on") + fmt.Println() + // Print each source with its connections for _, source := range sources { - fmt.Printf("šŸ”Œ %s URL: %s\n", source.Name, source.Url) - } -} + // Print the source URL + fmt.Printf("%s: %s\n", ansi.Bold(source.Name), source.Url) -func printConnections(config *config.Config, connections []*hookdecksdk.Connection) { - fmt.Println(ansi.Bold("Connections")) - for _, connection := range connections { - fmt.Println(*connection.FullName + " forwarding to " + *connection.Destination.CliPath) + // Print connections for this source + if sourceConns, exists := sourceConnections[source.Id]; exists { + for _, connection := range sourceConns { + // Calculate indentation based on source name length + indent := len(source.Name) + 2 // +2 for ": " + fullPath := targetURL.Scheme + "://" + targetURL.Host + *connection.Destination.CliPath + fmt.Printf("%*s↳ %s → %s\n", indent, "", *connection.Name, fullPath) + } + } } } From 7499f36b1d6308a6b4a154b13842aa54d65b09e2 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Wed, 1 Oct 2025 20:16:14 -0400 Subject: [PATCH 03/22] feat: Add new output and keyboard shortcut --- pkg/listen/printer.go | 2 +- pkg/proxy/proxy.go | 246 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 221 insertions(+), 27 deletions(-) diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index ce7b297..406b41b 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -27,7 +27,7 @@ func printDashboardInformation(config *config.Config, guestURL string) { } else { var url = config.DashboardBaseURL if config.Profile.ProjectId != "" { - url += "?team_id=" + config.Profile.ProjectId + url += "/events/cli?team_id=" + config.Profile.ProjectId } if config.Profile.ProjectMode == "console" { url = config.ConsoleBaseURL diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index c40130f..bbf2679 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -14,6 +14,7 @@ import ( "os/exec" "os/signal" "runtime" + "sort" "strconv" "strings" "sync" @@ -60,13 +61,19 @@ type Config struct { // webhook events, forwards them to the local endpoint and sends the response // back to Hookdeck. type Proxy struct { - cfg *Config - connections []*hookdecksdk.Connection - webSocketClient *websocket.Client - connectionTimer *time.Timer - latestEventID string - terminalMutex sync.Mutex - rawModeState *term.State + cfg *Config + connections []*hookdecksdk.Connection + webSocketClient *websocket.Client + connectionTimer *time.Timer + latestEventID string + latestEventStatus int + latestEventSuccess bool + latestEventTime time.Time + latestEventData *websocket.Attempt + hasReceivedEvent bool + statusLineShown bool + terminalMutex sync.Mutex + rawModeState *term.State } func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { @@ -103,6 +110,86 @@ func (p *Proxy) safePrintf(format string, args ...interface{}) { } } +// printEventAndUpdateStatus prints the event log and updates the status line in one operation +func (p *Proxy) printEventAndUpdateStatus(eventLog string) { + p.terminalMutex.Lock() + defer p.terminalMutex.Unlock() + + // Temporarily restore normal terminal mode for printing + if p.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), p.rawModeState) + } + + // If we have a previous status line, clear it and the blank line above it + if p.statusLineShown { + fmt.Printf("\033[2A\033[K\033[1B\033[K") + } + + // Print the event log + fmt.Printf("%s\n", eventLog) + + // Add a blank line for visual separation + fmt.Println() + + // Generate and print the new status message + var statusMsg string + color := ansi.Color(os.Stdout) + if p.latestEventSuccess { + statusMsg = fmt.Sprintf("> %s Last event succeeded with status %d āŒØļø [r] Retry • [o] Open link • [d] Details • [q] Quit", + color.Green("āœ“"), p.latestEventStatus) + } else { + statusMsg = fmt.Sprintf("> %s Last event failed with status %d āŒØļø [r] Retry • [o] Open link • [d] Details • [q] Quit", + color.Red("⨯"), p.latestEventStatus) + } + + fmt.Printf("%s\n", statusMsg) + p.statusLineShown = true + + // Re-enable raw mode + if p.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } +} + +// updateStatusLine updates the bottom status line with the latest event information +func (p *Proxy) updateStatusLine() { + p.terminalMutex.Lock() + defer p.terminalMutex.Unlock() + + // Temporarily restore normal terminal mode for printing + if p.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), p.rawModeState) + } + + var statusMsg string + if !p.hasReceivedEvent { + statusMsg = "> Ready..." + } else { + color := ansi.Color(os.Stdout) + if p.latestEventSuccess { + statusMsg = fmt.Sprintf("> %s Last event succeeded (%d) āŒØļø [r] Retry • [o] Open link • [d] Details • [q] Quit", + color.Green("āœ“"), p.latestEventStatus) + } else { + statusMsg = fmt.Sprintf("> %s Last event failed (%d) āŒØļø [r] Retry • [o] Open link • [d] Details • [q] Quit", + color.Red("āš ļø"), p.latestEventStatus) + } + } + + if p.statusLineShown { + // If we've shown a status before, move up one line and clear it + fmt.Printf("\033[1A\033[K%s\n", statusMsg) + } else { + // First time showing status + fmt.Printf("%s\n", statusMsg) + p.statusLineShown = true + } + + // Re-enable raw mode + if p.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } +} + func (p *Proxy) startKeyboardListener(ctx context.Context) { // Check if we're in a terminal if !term.IsTerminal(int(os.Stdin.Fd())) { @@ -167,6 +254,8 @@ func (p *Proxy) startKeyboardListener(ctx context.Context) { p.retryLatestEvent() case 0x6F, 0x4F: // 'o' or 'O' p.openLatestEventURL() + case 0x64, 0x44: // 'd' or 'D' + p.showLatestEventDetails() case 0x03: // Ctrl+C proc, _ := os.FindProcess(os.Getpid()) proc.Signal(os.Interrupt) @@ -243,20 +332,30 @@ func (p *Proxy) Run(parentCtx context.Context) error { // Monitor the websocket for connection and update the spinner appropriately. go func() { <-p.webSocketClient.Connected() - msg := "Ready... āŒØļø [r] Retry latest • [o] Open latest • [q] Quit" if hasConnectedOnce { - msg = "Reconnected!" - } - // Stop the spinner and print the message safely - p.terminalMutex.Lock() - if p.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), p.rawModeState) - } - ansi.StopSpinner(s, msg, p.cfg.Log.Out) - if p.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) + // Stop the spinner and print the message safely + p.terminalMutex.Lock() + if p.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), p.rawModeState) + } + ansi.StopSpinner(s, "Reconnected!", p.cfg.Log.Out) + if p.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } + p.terminalMutex.Unlock() + } else { + // Stop the spinner without a message and use our status line + p.terminalMutex.Lock() + if p.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), p.rawModeState) + } + ansi.StopSpinner(s, "", p.cfg.Log.Out) + if p.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } + p.terminalMutex.Unlock() + p.updateStatusLine() } - p.terminalMutex.Unlock() hasConnectedOnce = true }() @@ -360,8 +459,9 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { webhookEvent := msg.Attempt - // Store the latest event ID for retry/open functionality + // Store the latest event ID and data for retry/open/details functionality p.latestEventID = webhookEvent.Body.EventID + p.latestEventData = webhookEvent p.cfg.Log.WithFields(log.Fields{ "prefix": "proxy.Proxy.processAttempt", @@ -418,7 +518,15 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { err, ) - p.safePrintf("%s\n", errStr) + // Track the failed event first + p.latestEventStatus = 0 // Use 0 for connection errors + p.latestEventSuccess = false + p.latestEventTime = time.Now() + p.hasReceivedEvent = true + + // Print the error and update status line in one operation + p.printEventAndUpdateStatus(errStr) + p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ Event: "attempt_response", @@ -436,18 +544,26 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *http.Response) { localTime := time.Now().Format(timeLayout) color := ansi.Color(os.Stdout) - var url = p.cfg.DashboardBaseURL + "/events/cli/" + webhookEvent.Body.EventID + var url = p.cfg.DashboardBaseURL + "/events/" + webhookEvent.Body.EventID if p.cfg.ProjectMode == "console" { url = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID } - outputStr := fmt.Sprintf("%s [%d] %s %s | %s", + outputStr := fmt.Sprintf("%s [%d] %s %s %s %s", color.Faint(localTime), ansi.ColorizeStatus(resp.StatusCode), resp.Request.Method, resp.Request.URL, - url, + color.Faint("→"), + color.Faint(url), ) - p.safePrintf("%s\n", outputStr) + // Track the event status first + p.latestEventStatus = resp.StatusCode + p.latestEventSuccess = resp.StatusCode >= 200 && resp.StatusCode < 300 + p.latestEventTime = time.Now() + p.hasReceivedEvent = true + + // Print the event log and update status line in one operation + p.printEventAndUpdateStatus(outputStr) buf, err := ioutil.ReadAll(resp.Body) if err != nil { @@ -541,7 +657,7 @@ func (p *Proxy) openLatestEventURL() { if p.cfg.ProjectMode == "console" { eventURL = p.cfg.ConsoleBaseURL + "/?event_id=" + eventID } else { - eventURL = p.cfg.DashboardBaseURL + "/events/cli/" + eventID + eventURL = p.cfg.DashboardBaseURL + "/events/" + eventID } // Open URL in browser @@ -575,6 +691,84 @@ func (p *Proxy) openBrowser(url string) error { return exec.Command(cmd, args...).Start() } +func (p *Proxy) showLatestEventDetails() { + if p.latestEventData == nil { + color := ansi.Color(os.Stdout) + p.safePrintf("[%s] No event to show details for\n", + color.Yellow("WARN"), + ) + return + } + + webhookEvent := p.latestEventData + + p.terminalMutex.Lock() + defer p.terminalMutex.Unlock() + + // Temporarily restore normal terminal mode for printing + if p.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), p.rawModeState) + } + + // Clear the status line + if p.statusLineShown { + fmt.Printf("\033[2A\033[K\033[1B\033[K") + } + + // Print the event details with title + color := ansi.Color(os.Stdout) + fmt.Printf("│ %s %s%s\n", color.Bold(webhookEvent.Body.Request.Method), color.Bold(p.cfg.URL.String()), color.Bold(webhookEvent.Body.Path)) + fmt.Printf("│\n") + + // Parse and display headers (no title) + if len(webhookEvent.Body.Request.Headers) > 0 { + var headers map[string]json.RawMessage + if err := json.Unmarshal(webhookEvent.Body.Request.Headers, &headers); err == nil { + // Get keys and sort them alphabetically + keys := make([]string, 0, len(headers)) + for key := range headers { + keys = append(keys, key) + } + sort.Strings(keys) + + // Print headers in alphabetical order + for _, key := range keys { + unquoted, _ := strconv.Unquote(string(headers[key])) + fmt.Printf("│ %s: %s\n", strings.ToLower(key), unquoted) + } + } + } + + // Add blank line before body + fmt.Printf("│\n") + + // Display body (no title) + if len(webhookEvent.Body.Request.DataString) > 0 { + // Split body into lines and add left border to each line + bodyLines := strings.Split(webhookEvent.Body.Request.DataString, "\n") + for _, line := range bodyLines { + fmt.Printf("│ %s\n", line) + } + } + + // Restore the status line + fmt.Println() + var statusMsg string + if p.latestEventSuccess { + statusMsg = fmt.Sprintf("> %s Last event succeeded (%d) āŒØļø [r] Retry now • [o] Open link • [d] Details • [q] Quit", + color.Green("āœ“"), p.latestEventStatus) + } else { + statusMsg = fmt.Sprintf("> %s Last event failed (%d) āŒØļø [r] Retry now • [o] Open link • [d] Details • [q] Quit", + color.Red("āš ļø"), p.latestEventStatus) + } + fmt.Printf("%s\n", statusMsg) + + // Re-enable raw mode + if p.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } +} + // // Public functions // From da8c6fcec8bd1ccd646a6183b39cfbbb9bfc6c28 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Thu, 2 Oct 2025 22:09:50 -0400 Subject: [PATCH 04/22] wip --- README.md | 13 ++ pkg/listen/listen.go | 2 + pkg/proxy/proxy.go | 350 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 301 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 9061ba6..be603d5 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,19 @@ Hookdeck works by routing events received for a given `source` (i.e., Shopify, G Each `source` is assigned an Event URL, which you can use to receive events. When starting with a fresh account, the CLI will prompt you to create your first source. Each CLI process can listen to one source at a time. +#### Interactive Keyboard Shortcuts + +While the listen command is running, you can use the following keyboard shortcuts: + +- `↑` / `↓` - Navigate between events (select different events) +- `r` - Retry the selected event +- `o` - Open the selected event in the Hookdeck dashboard +- `d` - Show detailed request information for the selected event (headers, body, etc.) +- `q` - Quit the application +- `Ctrl+C` - Also quits the application + +The selected event is indicated by a `>` character at the beginning of the line. All actions (retry, open, details) work on the currently selected event, not just the latest one. These shortcuts are displayed in the status line at the bottom of the terminal. + Contrary to ngrok, **Hookdeck does not allow to append a path to your event URL**. Instead, the routing is done within Hookdeck configuration. This means you will also be prompted to specify your `destination` path, and you can have as many as you want per `source`. > The `port-or-URL` param is mandatory, events will be forwarded to http://localhost:$PORT/$DESTINATION_PATH when inputing a valid port or your provided URL. diff --git a/pkg/listen/listen.go b/pkg/listen/listen.go index 09a9041..74be38e 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -123,6 +123,8 @@ Specify a single destination to update the path. For example, pass a connection fmt.Println() printSourcesWithConnections(config, sources, connections, URL) fmt.Println() + fmt.Printf("─ %s ──────────────────────────────────────────────────────\n", "Events") + fmt.Println() p := proxy.New(&proxy.Config{ DeviceName: config.DeviceName, diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index bbf2679..3eaaada 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -57,6 +57,16 @@ type Config struct { Insecure bool } +// EventInfo represents a single event for navigation +type EventInfo struct { + ID string + Status int + Success bool + Time time.Time + Data *websocket.Attempt + LogLine string +} + // A Proxy opens a websocket connection with Hookdeck, listens for incoming // webhook events, forwards them to the local endpoint and sends the response // back to Hookdeck. @@ -74,6 +84,10 @@ type Proxy struct { statusLineShown bool terminalMutex sync.Mutex rawModeState *term.State + // Event navigation + eventHistory []EventInfo + selectedEventIndex int + userNavigated bool // Track if user has manually navigated away from latest event } func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { @@ -115,31 +129,99 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { p.terminalMutex.Lock() defer p.terminalMutex.Unlock() + // Add event to history + eventInfo := EventInfo{ + ID: p.latestEventID, + Status: p.latestEventStatus, + Success: p.latestEventSuccess, + Time: p.latestEventTime, + Data: p.latestEventData, + LogLine: eventLog, + } + p.eventHistory = append(p.eventHistory, eventInfo) + + // Auto-select the latest event unless user has navigated away + if !p.userNavigated { + p.selectedEventIndex = len(p.eventHistory) - 1 + } + // Temporarily restore normal terminal mode for printing if p.rawModeState != nil { term.Restore(int(os.Stdin.Fd()), p.rawModeState) } - // If we have a previous status line, clear it and the blank line above it + // If we have multiple events and auto-selection is enabled, redraw all events + if len(p.eventHistory) > 1 && !p.userNavigated { + // Move cursor up to the first event line + // From current position (after cursor), we need to move up: + // - 1 line for previous status + // - 1 line for blank line + // - (len(p.eventHistory) - 1) lines for all previous events + if p.statusLineShown { + linesToMoveUp := 1 + 1 + (len(p.eventHistory) - 1) + fmt.Printf("\033[%dA", linesToMoveUp) + } + + // Print all events with selection indicator, clearing each line + for i, event := range p.eventHistory { + fmt.Printf("\033[2K") // Clear the entire current line + if i == p.selectedEventIndex { + fmt.Printf("> %s\n", event.LogLine) + } else { + fmt.Printf(" %s\n", event.LogLine) + } + } + + // Add a newline before the status line (clear the line first) + fmt.Printf("\033[2K\n") + + // Generate and print the new status message + var statusMsg string + color := ansi.Color(os.Stdout) + if p.latestEventSuccess { + statusMsg = fmt.Sprintf("> %s Last event succeeded with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Green("āœ“"), p.latestEventStatus) + } else { + statusMsg = fmt.Sprintf("> %s Last event failed with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Red("x").Bold(), p.latestEventStatus) + } + + fmt.Printf("\033[2K%s\n", statusMsg) + p.statusLineShown = true + + // Re-enable raw mode + if p.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } + return + } + + // First event or user has navigated - simple case if p.statusLineShown { - fmt.Printf("\033[2A\033[K\033[1B\033[K") + // Clear the status line and blank line above it, then move back to the new event position + fmt.Printf("\033[2A\033[K\033[1B\033[K\033[1A") } - // Print the event log - fmt.Printf("%s\n", eventLog) + // Print the event log with selection indicator + newEventIndex := len(p.eventHistory) - 1 + if p.selectedEventIndex == newEventIndex { + fmt.Printf("> %s\n", p.eventHistory[newEventIndex].LogLine) + } else { + fmt.Printf(" %s\n", p.eventHistory[newEventIndex].LogLine) + } - // Add a blank line for visual separation + // Add a newline before the status line fmt.Println() // Generate and print the new status message var statusMsg string color := ansi.Color(os.Stdout) if p.latestEventSuccess { - statusMsg = fmt.Sprintf("> %s Last event succeeded with status %d āŒØļø [r] Retry • [o] Open link • [d] Details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Last event succeeded with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", color.Green("āœ“"), p.latestEventStatus) } else { - statusMsg = fmt.Sprintf("> %s Last event failed with status %d āŒØļø [r] Retry • [o] Open link • [d] Details • [q] Quit", - color.Red("⨯"), p.latestEventStatus) + statusMsg = fmt.Sprintf("> %s Last event failed with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Red("x").Bold(), p.latestEventStatus) } fmt.Printf("%s\n", statusMsg) @@ -163,15 +245,15 @@ func (p *Proxy) updateStatusLine() { var statusMsg string if !p.hasReceivedEvent { - statusMsg = "> Ready..." + statusMsg = "Connected. Waiting for events..." } else { color := ansi.Color(os.Stdout) if p.latestEventSuccess { - statusMsg = fmt.Sprintf("> %s Last event succeeded (%d) āŒØļø [r] Retry • [o] Open link • [d] Details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Last event succeeded (%d) āŒØļø [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", color.Green("āœ“"), p.latestEventStatus) } else { - statusMsg = fmt.Sprintf("> %s Last event failed (%d) āŒØļø [r] Retry • [o] Open link • [d] Details • [q] Quit", - color.Red("āš ļø"), p.latestEventStatus) + statusMsg = fmt.Sprintf("> %s Last event failed (%d) āŒØļø [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Red("x").Bold(), p.latestEventStatus) } } @@ -179,8 +261,8 @@ func (p *Proxy) updateStatusLine() { // If we've shown a status before, move up one line and clear it fmt.Printf("\033[1A\033[K%s\n", statusMsg) } else { - // First time showing status - fmt.Printf("%s\n", statusMsg) + // First time showing status - add a newline before it for spacing + fmt.Printf("\n%s\n", statusMsg) p.statusLineShown = true } @@ -209,28 +291,33 @@ func (p *Proxy) startKeyboardListener(ctx context.Context) { // Ensure we restore terminal state when this goroutine exits defer func() { p.terminalMutex.Lock() + defer p.terminalMutex.Unlock() term.Restore(int(os.Stdin.Fd()), oldState) - p.terminalMutex.Unlock() }() // Create a buffered channel for reading stdin - inputCh := make(chan byte, 1) + inputCh := make(chan []byte, 1) // Start a separate goroutine to read from stdin go func() { defer close(inputCh) - buf := make([]byte, 1) + buf := make([]byte, 3) // Buffer for escape sequences for { select { case <-ctx.Done(): return default: n, err := os.Stdin.Read(buf) - if err != nil || n != 1 { + if err != nil { + // Log the error but don't crash the application + log.WithField("prefix", "proxy.startKeyboardListener").Debugf("Error reading stdin: %v", err) return } + if n == 0 { + continue + } select { - case inputCh <- buf[0]: + case inputCh <- buf[:n]: case <-ctx.Done(): return } @@ -243,33 +330,134 @@ func (p *Proxy) startKeyboardListener(ctx context.Context) { select { case <-ctx.Done(): return - case key, ok := <-inputCh: + case input, ok := <-inputCh: if !ok { return } - // Process the key - switch key { - case 0x72, 0x52: // 'r' or 'R' - p.retryLatestEvent() - case 0x6F, 0x4F: // 'o' or 'O' - p.openLatestEventURL() - case 0x64, 0x44: // 'd' or 'D' - p.showLatestEventDetails() - case 0x03: // Ctrl+C - proc, _ := os.FindProcess(os.Getpid()) - proc.Signal(os.Interrupt) - return - case 0x71, 0x51: // 'q' or 'Q' - proc, _ := os.FindProcess(os.Getpid()) - proc.Signal(os.Interrupt) - return - } + // Process the input + p.processKeyboardInput(input) } } }() } +// processKeyboardInput handles keyboard input including arrow keys +func (p *Proxy) processKeyboardInput(input []byte) { + if len(input) == 0 { + return + } + + // Handle escape sequences (arrow keys) + if len(input) == 3 && input[0] == 0x1B && input[1] == 0x5B { + switch input[2] { + case 0x41: // Up arrow + p.navigateEvents(-1) + case 0x42: // Down arrow + p.navigateEvents(1) + } + return + } + + // Handle single character keys + if len(input) == 1 { + switch input[0] { + case 0x72, 0x52: // 'r' or 'R' + p.retrySelectedEvent() + case 0x6F, 0x4F: // 'o' or 'O' + p.openSelectedEventURL() + case 0x64, 0x44: // 'd' or 'D' + p.showSelectedEventDetails() + case 0x03: // Ctrl+C + proc, _ := os.FindProcess(os.Getpid()) + proc.Signal(os.Interrupt) + return + case 0x71, 0x51: // 'q' or 'Q' + proc, _ := os.FindProcess(os.Getpid()) + proc.Signal(os.Interrupt) + return + } + } +} + +// navigateEvents moves the selection up or down in the event history +func (p *Proxy) navigateEvents(direction int) { + if len(p.eventHistory) == 0 { + return + } + + newIndex := p.selectedEventIndex + direction + if newIndex < 0 { + newIndex = 0 + } else if newIndex >= len(p.eventHistory) { + newIndex = len(p.eventHistory) - 1 + } + + if newIndex != p.selectedEventIndex { + p.selectedEventIndex = newIndex + p.userNavigated = true // Mark that user has manually navigated + + // Reset userNavigated if user navigates back to the latest event + if newIndex == len(p.eventHistory)-1 { + p.userNavigated = false + } + + p.redrawEventsWithSelection() + } +} + +// redrawEventsWithSelection updates the selection indicators without clearing the screen +func (p *Proxy) redrawEventsWithSelection() { + if len(p.eventHistory) == 0 { + return + } + + p.terminalMutex.Lock() + defer p.terminalMutex.Unlock() + + // Temporarily restore normal terminal mode for printing + if p.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), p.rawModeState) + } + + // Move cursor up to redraw all events with correct selection indicators + // We need to move up (number of events + 1 for blank line + 1 for status line) lines + totalLines := len(p.eventHistory) + 2 + fmt.Printf("\033[%dA", totalLines) + + // Print all events with selection indicator, clearing each line first + for i, event := range p.eventHistory { + fmt.Printf("\033[2K") // Clear the entire current line + if i == p.selectedEventIndex { + fmt.Printf("> %s\n", event.LogLine) + } else { + fmt.Printf(" %s\n", event.LogLine) + } + } + + // Add a newline before the status line + fmt.Printf("\033[2K\n") // Clear entire line and add newline + + // Generate and print the status message for the selected event + var statusMsg string + color := ansi.Color(os.Stdout) + if p.eventHistory[p.selectedEventIndex].Success { + statusMsg = fmt.Sprintf("> %s Selected event succeeded with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Green("āœ“"), p.eventHistory[p.selectedEventIndex].Status) + } else { + statusMsg = fmt.Sprintf("> %s Selected event failed with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Red("⨯"), p.eventHistory[p.selectedEventIndex].Status) + } + + fmt.Printf("\033[2K%s\n", statusMsg) // Clear entire line and print status + p.statusLineShown = true + + // Re-enable raw mode + if p.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } +} + // Run manages the connection to Hookdeck. // The connection is established in phases: // - Create a new CLI session @@ -513,7 +701,7 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { errStr := fmt.Sprintf("%s [%s] Failed to %s: %v", color.Faint(localTime), - color.Red("ERROR"), + color.Red("ERROR").Bold(), webhookEvent.Body.Request.Method, err, ) @@ -569,7 +757,7 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h if err != nil { errStr := fmt.Sprintf("%s [%s] Failed to read response from endpoint, error = %v\n", color.Faint(localTime), - color.Red("ERROR"), + color.Red("ERROR").Bold(), err, ) log.Errorf(errStr) @@ -591,12 +779,19 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h } } -func (p *Proxy) retryLatestEvent() { - eventID := p.latestEventID +func (p *Proxy) retrySelectedEvent() { + if len(p.eventHistory) == 0 || p.selectedEventIndex < 0 || p.selectedEventIndex >= len(p.eventHistory) { + color := ansi.Color(os.Stdout) + p.safePrintf("[%s] No event selected to retry\n", + color.Yellow("WARN"), + ) + return + } + eventID := p.eventHistory[p.selectedEventIndex].ID if eventID == "" { color := ansi.Color(os.Stdout) - p.safePrintf("[%s] No event to retry\n", + p.safePrintf("[%s] Selected event has no ID to retry\n", color.Yellow("WARN"), ) return @@ -607,7 +802,7 @@ func (p *Proxy) retryLatestEvent() { if err != nil { color := ansi.Color(os.Stdout) p.safePrintf("[%s] Failed to parse API URL for retry: %v\n", - color.Red("ERROR"), + color.Red("ERROR").Bold(), err, ) return @@ -624,9 +819,10 @@ func (p *Proxy) retryLatestEvent() { resp, err := client.Post(context.Background(), retryURL, []byte("{}"), nil) if err != nil { color := ansi.Color(os.Stdout) - p.safePrintf("[%s] Failed to retry event %s\n", - color.Red("ERROR"), + p.safePrintf("[%s] Failed to retry event %s: %v\n", + color.Red("ERROR").Bold(), eventID, + err, ) return } @@ -634,19 +830,35 @@ func (p *Proxy) retryLatestEvent() { if resp.StatusCode != http.StatusOK { color := ansi.Color(os.Stdout) - p.safePrintf("[%s] Failed to retry event %s\n", - color.Red("ERROR"), + p.safePrintf("[%s] Failed to retry event %s (status: %d)\n", + color.Red("ERROR").Bold(), eventID, + resp.StatusCode, ) + return } + + // Success feedback + color := ansi.Color(os.Stdout) + p.safePrintf("[%s] Event %s retry requested successfully\n", + color.Green("SUCCESS"), + eventID, + ) } -func (p *Proxy) openLatestEventURL() { - eventID := p.latestEventID +func (p *Proxy) openSelectedEventURL() { + if len(p.eventHistory) == 0 || p.selectedEventIndex < 0 || p.selectedEventIndex >= len(p.eventHistory) { + color := ansi.Color(os.Stdout) + p.safePrintf("[%s] No event selected to open\n", + color.Yellow("WARN"), + ) + return + } + eventID := p.eventHistory[p.selectedEventIndex].ID if eventID == "" { color := ansi.Color(os.Stdout) - p.safePrintf("[%s] No event to open\n", + p.safePrintf("[%s] Selected event has no ID to open\n", color.Yellow("WARN"), ) return @@ -665,7 +877,7 @@ func (p *Proxy) openLatestEventURL() { if err != nil { color := ansi.Color(os.Stdout) p.safePrintf("[%s] Failed to open browser: %v\n", - color.Red("ERROR"), + color.Red("ERROR").Bold(), err, ) return @@ -691,16 +903,25 @@ func (p *Proxy) openBrowser(url string) error { return exec.Command(cmd, args...).Start() } -func (p *Proxy) showLatestEventDetails() { - if p.latestEventData == nil { +func (p *Proxy) showSelectedEventDetails() { + if len(p.eventHistory) == 0 || p.selectedEventIndex < 0 || p.selectedEventIndex >= len(p.eventHistory) { color := ansi.Color(os.Stdout) - p.safePrintf("[%s] No event to show details for\n", + p.safePrintf("[%s] No event selected to show details for\n", color.Yellow("WARN"), ) return } - webhookEvent := p.latestEventData + selectedEvent := p.eventHistory[p.selectedEventIndex] + if selectedEvent.Data == nil { + color := ansi.Color(os.Stdout) + p.safePrintf("[%s] Selected event has no data to show details for\n", + color.Yellow("WARN"), + ) + return + } + + webhookEvent := selectedEvent.Data p.terminalMutex.Lock() defer p.terminalMutex.Unlock() @@ -710,7 +931,7 @@ func (p *Proxy) showLatestEventDetails() { term.Restore(int(os.Stdin.Fd()), p.rawModeState) } - // Clear the status line + // Clear the status line and the blank line above it if p.statusLineShown { fmt.Printf("\033[2A\033[K\033[1B\033[K") } @@ -754,12 +975,12 @@ func (p *Proxy) showLatestEventDetails() { // Restore the status line fmt.Println() var statusMsg string - if p.latestEventSuccess { - statusMsg = fmt.Sprintf("> %s Last event succeeded (%d) āŒØļø [r] Retry now • [o] Open link • [d] Details • [q] Quit", - color.Green("āœ“"), p.latestEventStatus) + if selectedEvent.Success { + statusMsg = fmt.Sprintf("> %s Selected event succeeded (%d) āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Green("āœ“"), selectedEvent.Status) } else { - statusMsg = fmt.Sprintf("> %s Last event failed (%d) āŒØļø [r] Retry now • [o] Open link • [d] Details • [q] Quit", - color.Red("āš ļø"), p.latestEventStatus) + statusMsg = fmt.Sprintf("> %s Selected event failed (%d) āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Red("⨯"), selectedEvent.Status) } fmt.Printf("%s\n", statusMsg) @@ -780,9 +1001,10 @@ func New(cfg *Config, connections []*hookdecksdk.Connection) *Proxy { } p := &Proxy{ - cfg: cfg, - connections: connections, - connectionTimer: time.NewTimer(0), // Defaults to no delay + cfg: cfg, + connections: connections, + connectionTimer: time.NewTimer(0), // Defaults to no delay + selectedEventIndex: -1, // Initialize to invalid index } return p From cedadcfd84af239de2daf13e8ef9b106e91d41d5 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Thu, 2 Oct 2025 22:45:15 -0400 Subject: [PATCH 05/22] wip --- MANUAL_TEST_PLAN.md | 386 ++++++++++++++++++++++++++++++++++++++++++ pkg/listen/listen.go | 7 +- pkg/listen/printer.go | 81 +++++---- pkg/proxy/proxy.go | 69 +++++++- 4 files changed, 503 insertions(+), 40 deletions(-) create mode 100644 MANUAL_TEST_PLAN.md diff --git a/MANUAL_TEST_PLAN.md b/MANUAL_TEST_PLAN.md new file mode 100644 index 0000000..c89b899 --- /dev/null +++ b/MANUAL_TEST_PLAN.md @@ -0,0 +1,386 @@ +# Manual Test Plan - Hookdeck CLI Listen Command + +This document outlines all the scenarios that should be manually tested for the `listen` command with the new UI improvements. + +--- + +## 1. Connection & Setup Scenarios + +### 1.1 Normal Connection +- **Test:** Start CLI with valid credentials and existing source +- **Expected:** + - Shows "Listening on" section with source details + - Shows animated green dot "ā— Connected. Waiting for events..." + - Websocket connects successfully + +### 1.2 First-Time Guest User (No API Key) +- **Test:** Run CLI without authentication +- **Expected:** + - Guest login flow triggers + - Shows: "šŸ’” Sign up to make your webhook URL permanent: [URL]" + - Creates temporary source + +### 1.3 WebSocket Connection Failure +- **Test:** Simulate websocket connection error (network issue, server down) +- **Expected:** + - Error message displayed + - Retry logic kicks in (up to 3 attempts) + - Graceful failure message if all attempts fail + +### 1.4 Invalid API Key +- **Test:** Use invalid/expired API key +- **Expected:** + - Authentication error message + - CLI exits with appropriate error + +--- + +## 2. Source Configuration Scenarios + +### 2.1 Single Source, Single Connection +- **Test:** Standard case with one source and one connection +- **Expected:** +``` +test +ā”œā”€ Request sent to → [webhook URL] +└─ Forwards to → [local URL] (connection_name) +``` + +### 2.2 Single Source, Multiple Connections +- **Test:** One source with 2-3 connections +- **Expected:** +``` +shopify +ā”œā”€ Request sent to → [webhook URL] +ā”œā”€ Forwards to → [local URL 1] (connection_1) +└─ Forwards to → [local URL 2] (connection_2) +``` + +### 2.3 Multiple Sources, Multiple Connections +- **Test:** 2-3 sources, each with 1-2 connections +- **Expected:** + - Each source shown with tree structure + - Blank line between sources + - All properly aligned + +### 2.4 Source Without Connections +- **Test:** Source exists but has no CLI connections configured +- **Expected:** + - Should show error or warning + - CLI should handle gracefully + +### 2.5 Non-Existent Source +- **Test:** Specify source name that doesn't exist +- **Expected:** + - Source gets created automatically + - Shows new source in "Listening on" section + +--- + +## 3. Event Handling Scenarios + +### 3.1 First Event Received +- **Test:** Send first webhook to source +- **Expected:** + - Animated green dot stops + - Event displays with proper formatting + - Status line shows: "> āœ“ Last event succeeded..." or "> x Last event failed..." + - Event auto-selected (has `>` indicator) + - Proper blank line before first event + +### 3.2 Successful Event (2xx Status) +- **Test:** Send webhook that returns 200-299 status +- **Expected:** + - Green āœ“ icon in status + - Event shows `[200]` or appropriate status code + - Status: "> āœ“ Last event succeeded with status 200" + +### 3.3 Failed Event (4xx/5xx Status) +- **Test:** Send webhook that returns 400/500 status +- **Expected:** + - Red x (bold) in status + - Event shows `[400]` or `[500]` + - Status: "> x Last event failed with status 500" + - Same red color for "x" and status code + +### 3.4 Multiple Events (Auto-selection) +- **Test:** Send 3-4 events without navigating +- **Expected:** + - Each new event auto-selected (gets `>`) + - Previous events show ` ` (no selection) + - Only ONE blank line between last event and status + - No duplicate events + - No duplicate status lines + +### 3.5 Connection Error (Local Server Down) +- **Test:** Stop local server, send webhook +- **Expected:** + - ERROR displayed with connection error message + - Status shows error with status 0 or similar + - Error tracked in event history + +--- + +## 4. Keyboard Navigation Scenarios + +### 4.1 Arrow Up Navigation +- **Test:** Press ↑ after receiving 3+ events +- **Expected:** + - `>` moves to previous event + - Status updates to reflect selected event + - `userNavigated` flag set + - No screen clearing (initial content preserved) + - No duplicate rows + +### 4.2 Arrow Down Navigation +- **Test:** Navigate up, then press ↓ +- **Expected:** + - `>` moves to next event + - Status updates correctly + - Can navigate back to latest event + +### 4.3 Navigate to First Event +- **Test:** Navigate all the way to first event (↑ multiple times) +- **Expected:** + - Stops at first event (index 0) + - `>` on first event + - Status reflects first event details + +### 4.4 Navigate to Last Event +- **Test:** Navigate down to last event +- **Expected:** + - Stops at last event + - `>` on last event + - Auto-selection resumes (userNavigated = false) + +### 4.5 New Event While Navigated +- **Test:** Navigate to old event, then new webhook arrives +- **Expected:** + - New event appears with ` ` (not selected) + - User stays on previously selected event + - Extra spacing handled correctly + - Selection doesn't jump to new event + +### 4.6 Navigate Back to Latest +- **Test:** Navigate away, then navigate back to latest event +- **Expected:** + - Auto-selection resumes for future events + - `userNavigated` flag reset + +--- + +## 5. Keyboard Actions + +### 5.1 Retry Event (r/R) +- **Test:** Select failed event, press 'r' +- **Expected:** + - API call to retry event + - Success/error message displayed + - Original status line restored + +### 5.2 Open in Dashboard (o/O) +- **Test:** Select any event, press 'o' +- **Expected:** + - Browser opens to event details page + - Correct URL with event ID + +### 5.3 Show Event Details (d/D) +- **Test:** Select any event, press 'd' +- **Expected:** + - Request details displayed (headers, body) + - Formatted nicely + - Status line restored after viewing + +### 5.4 Quit (q/Q) +- **Test:** Press 'q' +- **Expected:** + - Terminal restored to normal mode + - Clean exit + - No leftover artifacts + +### 5.5 Ctrl+C +- **Test:** Press Ctrl+C +- **Expected:** + - Same as quit + - Clean shutdown + - WebSocket connection closed properly + +--- + +## 6. Terminal Display Scenarios + +### 6.1 Waiting State Animation +- **Test:** Start CLI, don't send events for 5+ seconds +- **Expected:** + - Green dot alternates between ā— and ā—‹ every 500ms + - "Connected. Waiting for events..." message + - Animation smooth and visible + +### 6.2 Status Line Updates +- **Test:** Send multiple events, observe status line +- **Expected:** + - Status line always shows selected event info + - Proper clearing (no duplicate status lines) + - Keyboard shortcuts shown: [↑↓] Navigate • [r] Retry • [o] Open • [d] Details • [q] Quit + +### 6.3 Screen Clearing Behavior +- **Test:** Navigate between events +- **Expected:** + - Initial content (Listening on, hint, Events) preserved + - Only event area and status redrawn + - No flickering + - Clean transitions + +### 6.4 Long URLs +- **Test:** Use very long webhook URLs or local URLs +- **Expected:** + - URLs don't break formatting + - Tree structure maintained + - Still readable + +### 6.5 Many Events (10+) +- **Test:** Send 10-20 events rapidly +- **Expected:** + - All events displayed + - Scrolling works naturally + - Navigation works through all events + - Performance acceptable + +--- + +## 7. Edge Cases + +### 7.1 Empty Connection Name +- **Test:** Connection with empty or null name +- **Expected:** + - Handles gracefully (shows ID or default name) + +### 7.2 Special Characters in Names +- **Test:** Source/connection names with emoji, unicode, special chars +- **Expected:** + - Displays correctly + - No formatting issues + - Tree structure preserved + +### 7.3 Very Long Source Names +- **Test:** Source name with 50+ characters +- **Expected:** + - Displays without breaking layout + - Tree structure maintained + +### 7.4 Rapid Event Bursts +- **Test:** Send 5 events within 1 second +- **Expected:** + - All events captured + - Display updates correctly + - No race conditions + - No missing events + +### 7.5 Terminal Resize +- **Test:** Resize terminal window while running +- **Expected:** + - Layout adjusts reasonably + - No crashes + - Content still readable + +### 7.6 No Events for Extended Period +- **Test:** Let CLI run for 5+ minutes without events +- **Expected:** + - Animation continues smoothly + - No memory leaks + - Still responsive to input + +--- + +## 8. Multi-Source Scenarios + +### 8.1 Listen to All Sources (*) +- **Test:** `hookdeck listen 3000 *` +- **Expected:** + - All sources with CLI connections shown + - Multi-source message displayed + - Each source in tree format + +### 8.2 Switch Between Sources +- **Test:** Receive events from different sources +- **Expected:** + - Events grouped by source or shown chronologically + - Can identify which source each event came from + +--- + +## 9. Path Configuration + +### 9.1 Custom Path Flag +- **Test:** `hookdeck listen 3000 source --path /webhooks` +- **Expected:** + - Path shown in "Forwards to" URL + - Requests forwarded to correct path + +### 9.2 Path Update +- **Test:** Change path for existing destination +- **Expected:** + - Path update message shown + - New path reflected in display + +--- + +## 10. Console Mode + +### 10.1 Console Mode User +- **Test:** User in console mode (not dashboard mode) +- **Expected:** + - Console URL shown instead of dashboard URL + - šŸ’” hint shows console link + +--- + +## Testing Checklist + +Use this checklist to track testing progress: + +- [ ] 1.1 Normal Connection +- [ ] 1.2 Guest User +- [ ] 1.3 WebSocket Failure +- [ ] 1.4 Invalid API Key +- [ ] 2.1 Single Source/Connection +- [ ] 2.2 Multiple Connections +- [ ] 2.3 Multiple Sources +- [ ] 2.4 Source Without Connections +- [ ] 2.5 Non-Existent Source +- [ ] 3.1 First Event +- [ ] 3.2 Successful Event +- [ ] 3.3 Failed Event +- [ ] 3.4 Multiple Events +- [ ] 3.5 Connection Error +- [ ] 4.1-4.6 All Navigation Tests +- [ ] 5.1-5.5 All Keyboard Actions +- [ ] 6.1-6.5 All Display Tests +- [ ] 7.1-7.6 All Edge Cases +- [ ] 8.1-8.2 Multi-Source Tests +- [ ] 9.1-9.2 Path Tests +- [ ] 10.1 Console Mode + +--- + +## Known Issues to Watch For + +Based on previous fixes, pay special attention to: +1. Duplicate events appearing +2. Duplicate status lines +3. Missing newlines or extra newlines +4. Selection indicator (`>`) showing on multiple events +5. Screen clearing issues during navigation +6. First row duplication after navigation +7. Color consistency (red for errors) +8. Animation continuing after first event + +--- + +## Testing Environment + +- **Terminal:** iTerm2, Terminal.app, VSCode Terminal, etc. +- **OS:** macOS, Linux, Windows +- **Network:** Test with/without stable connection +- **Local Server:** Test with running/stopped local server + diff --git a/pkg/listen/listen.go b/pkg/listen/listen.go index 74be38e..bec9118 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -23,6 +23,7 @@ import ( "regexp" "strings" + "github.com/hookdeck/hookdeck-cli/pkg/ansi" "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/login" "github.com/hookdeck/hookdeck-cli/pkg/proxy" @@ -119,11 +120,9 @@ Specify a single destination to update the path. For example, pass a connection // Start proxy printListenMessage(config, isMultiSource) fmt.Println() - printDashboardInformation(config, guestURL) + printSourcesWithConnections(config, sources, connections, URL, guestURL) fmt.Println() - printSourcesWithConnections(config, sources, connections, URL) - fmt.Println() - fmt.Printf("─ %s ──────────────────────────────────────────────────────\n", "Events") + fmt.Printf("%s\n", ansi.Faint("Events")) fmt.Println() p := proxy.New(&proxy.Config{ diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index 406b41b..ad236c8 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -18,27 +18,7 @@ func printListenMessage(config *config.Config, isMultiSource bool) { fmt.Println("Listening for events on Sources that have Connections with CLI Destinations") } -func printDashboardInformation(config *config.Config, guestURL string) { - - if guestURL != "" { - fmt.Printf("─ %s ──────────────────────────────────────────────────────\n", "Console") - fmt.Println() - fmt.Println("šŸ‘‰ Sign up to make your webhook URL permanent: %s", guestURL) - } else { - var url = config.DashboardBaseURL - if config.Profile.ProjectId != "" { - url += "/events/cli?team_id=" + config.Profile.ProjectId - } - if config.Profile.ProjectMode == "console" { - url = config.ConsoleBaseURL - } - fmt.Printf("─ %s ──────────────────────────────────────────────────────\n", "Dashboard") - fmt.Println() - fmt.Printf("šŸ‘‰ Inspect, retry & boomark events: %s\n", url) - } -} - -func printSourcesWithConnections(config *config.Config, sources []*hookdecksdk.Source, connections []*hookdecksdk.Connection, targetURL *url.URL) { +func printSourcesWithConnections(config *config.Config, sources []*hookdecksdk.Source, connections []*hookdecksdk.Connection, targetURL *url.URL, guestURL string) { // Group connections by source ID sourceConnections := make(map[string][]*hookdecksdk.Connection) for _, connection := range connections { @@ -47,22 +27,65 @@ func printSourcesWithConnections(config *config.Config, sources []*hookdecksdk.S } // Print the Sources title line - fmt.Printf("─ %s ───────────────────────────────────────────────────\n", "Listening on") + fmt.Printf("%s\n", ansi.Faint("Listening on")) fmt.Println() // Print each source with its connections - for _, source := range sources { - // Print the source URL - fmt.Printf("%s: %s\n", ansi.Bold(source.Name), source.Url) + for i, source := range sources { + // Print source name + fmt.Printf("%s\n", ansi.Bold(source.Name)) // Print connections for this source if sourceConns, exists := sourceConnections[source.Id]; exists { - for _, connection := range sourceConns { - // Calculate indentation based on source name length - indent := len(source.Name) + 2 // +2 for ": " + numConns := len(sourceConns) + + // Print webhook URL with tree connector + if numConns > 0 { + fmt.Printf("ā”œā”€ Request sent to → %s\n", source.Url) + } else { + fmt.Printf("└─ Request sent to → %s\n", source.Url) + } + + // Print each connection + for j, connection := range sourceConns { fullPath := targetURL.Scheme + "://" + targetURL.Host + *connection.Destination.CliPath - fmt.Printf("%*s↳ %s → %s\n", indent, "", *connection.Name, fullPath) + + if j == numConns-1 { + // Last connection - use └─ + fmt.Printf("└─ Forwards to → %s %s\n", fullPath, ansi.Faint(fmt.Sprintf("(%s)", *connection.Name))) + } else { + // Not last connection - use ā”œā”€ + fmt.Printf("ā”œā”€ Forwards to → %s %s\n", fullPath, ansi.Faint(fmt.Sprintf("(%s)", *connection.Name))) + } } + } else { + // No connections, just show webhook URL + fmt.Printf("└─ Request sent to → %s\n", source.Url) + } + + // Add spacing between sources (but not after the last one) + if i < len(sources)-1 { + fmt.Println() + } + } + + // Print dashboard hint + fmt.Println() + if guestURL != "" { + fmt.Printf("šŸ’” Sign up to make your webhook URL permanent: %s\n", guestURL) + } else { + var url = config.DashboardBaseURL + var displayURL = config.DashboardBaseURL + if config.Profile.ProjectId != "" { + url += "/events/cli?team_id=" + config.Profile.ProjectId + displayURL += "/events/cli" + } + if config.Profile.ProjectMode == "console" { + url = config.ConsoleBaseURL + displayURL = config.ConsoleBaseURL } + // Create clickable link with OSC 8 hyperlink sequence + // Format: \033]8;;URL\033\\DISPLAY_TEXT\033]8;;\033\\ + fmt.Printf("šŸ’” View dashboard to inspect, retry & boomark events: \033]8;;%s\033\\%s\033]8;;\033\\\n", url, displayURL) } } diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 3eaaada..7d52f1a 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -88,6 +88,9 @@ type Proxy struct { eventHistory []EventInfo selectedEventIndex int userNavigated bool // Track if user has manually navigated away from latest event + // Waiting animation + waitingAnimationFrame int + stopWaitingAnimation chan bool } func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { @@ -198,8 +201,13 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { // First event or user has navigated - simple case if p.statusLineShown { - // Clear the status line and blank line above it, then move back to the new event position - fmt.Printf("\033[2A\033[K\033[1B\033[K\033[1A") + if len(p.eventHistory) == 1 { + // First event - only clear the status line (cursor is already at the right position) + fmt.Printf("\033[1A\033[K") + } else { + // Subsequent events - clear the status line and blank line above it, then move back to the new event position + fmt.Printf("\033[2A\033[K\033[1B\033[K\033[1A") + } } // Print the event log with selection indicator @@ -233,6 +241,29 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { } } +// startWaitingAnimation starts an animation for the waiting indicator +func (p *Proxy) startWaitingAnimation(ctx context.Context) { + p.stopWaitingAnimation = make(chan bool, 1) + + go func() { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-p.stopWaitingAnimation: + return + case <-ticker.C: + if !p.hasReceivedEvent && p.statusLineShown { + p.updateStatusLine() + } + } + } + }() +} + // updateStatusLine updates the bottom status line with the latest event information func (p *Proxy) updateStatusLine() { p.terminalMutex.Lock() @@ -245,7 +276,16 @@ func (p *Proxy) updateStatusLine() { var statusMsg string if !p.hasReceivedEvent { - statusMsg = "Connected. Waiting for events..." + // Animated green dot (alternates between ā— and ā—‹) + color := ansi.Color(os.Stdout) + var dot string + if p.waitingAnimationFrame%2 == 0 { + dot = fmt.Sprintf("%s", color.Green("ā—")) + } else { + dot = fmt.Sprintf("%s", color.Green("ā—‹")) + } + p.waitingAnimationFrame++ + statusMsg = fmt.Sprintf("%s Connected. Waiting for events...", dot) } else { color := ansi.Color(os.Stdout) if p.latestEventSuccess { @@ -261,8 +301,8 @@ func (p *Proxy) updateStatusLine() { // If we've shown a status before, move up one line and clear it fmt.Printf("\033[1A\033[K%s\n", statusMsg) } else { - // First time showing status - add a newline before it for spacing - fmt.Printf("\n%s\n", statusMsg) + // First time showing status + fmt.Printf("%s\n", statusMsg) p.statusLineShown = true } @@ -489,6 +529,9 @@ func (p *Proxy) Run(parentCtx context.Context) error { // Start keyboard listener for Ctrl+R/Cmd+R shortcuts p.startKeyboardListener(signalCtx) + // Start waiting animation + p.startWaitingAnimation(signalCtx) + s := ansi.StartNewSpinner("Getting ready...", p.cfg.Log.Out) session, err := p.createSession(signalCtx) @@ -710,7 +753,13 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { p.latestEventStatus = 0 // Use 0 for connection errors p.latestEventSuccess = false p.latestEventTime = time.Now() - p.hasReceivedEvent = true + if !p.hasReceivedEvent { + p.hasReceivedEvent = true + // Stop the waiting animation + if p.stopWaitingAnimation != nil { + p.stopWaitingAnimation <- true + } + } // Print the error and update status line in one operation p.printEventAndUpdateStatus(errStr) @@ -748,7 +797,13 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h p.latestEventStatus = resp.StatusCode p.latestEventSuccess = resp.StatusCode >= 200 && resp.StatusCode < 300 p.latestEventTime = time.Now() - p.hasReceivedEvent = true + if !p.hasReceivedEvent { + p.hasReceivedEvent = true + // Stop the waiting animation + if p.stopWaitingAnimation != nil { + p.stopWaitingAnimation <- true + } + } // Print the event log and update status line in one operation p.printEventAndUpdateStatus(outputStr) From 0277a924d5e035bdd7c0efb614f3be781cd7ce0f Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Thu, 2 Oct 2025 23:26:41 -0400 Subject: [PATCH 06/22] chore: Add event request --- pkg/proxy/proxy.go | 226 +++++++++++++++++++++++++++++++++------------ 1 file changed, 169 insertions(+), 57 deletions(-) diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 7d52f1a..d637825 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -91,6 +91,8 @@ type Proxy struct { // Waiting animation waitingAnimationFrame int stopWaitingAnimation chan bool + // Details view + showingDetails bool // Track if we're in alternate screen showing details } func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { @@ -388,8 +390,32 @@ func (p *Proxy) processKeyboardInput(input []byte) { return } + // Handle single character keys + if len(input) == 1 { + switch input[0] { + case 0x03: // Ctrl+C + proc, _ := os.FindProcess(os.Getpid()) + proc.Signal(os.Interrupt) + return + case 0x71, 0x51: // 'q' or 'Q' + proc, _ := os.FindProcess(os.Getpid()) + proc.Signal(os.Interrupt) + return + } + } + + // Disable all other shortcuts until first event is received + if !p.hasReceivedEvent { + return + } + // Handle escape sequences (arrow keys) if len(input) == 3 && input[0] == 0x1B && input[1] == 0x5B { + // Disable navigation while in details view + if p.showingDetails { + return + } + switch input[2] { case 0x41: // Up arrow p.navigateEvents(-1) @@ -399,23 +425,22 @@ func (p *Proxy) processKeyboardInput(input []byte) { return } - // Handle single character keys + // Handle single character keys (after quit/ctrl+c check) if len(input) == 1 { switch input[0] { case 0x72, 0x52: // 'r' or 'R' - p.retrySelectedEvent() + if !p.showingDetails { + p.retrySelectedEvent() + } case 0x6F, 0x4F: // 'o' or 'O' p.openSelectedEventURL() case 0x64, 0x44: // 'd' or 'D' - p.showSelectedEventDetails() - case 0x03: // Ctrl+C - proc, _ := os.FindProcess(os.Getpid()) - proc.Signal(os.Interrupt) - return - case 0x71, 0x51: // 'q' or 'Q' - proc, _ := os.FindProcess(os.Getpid()) - proc.Signal(os.Interrupt) - return + // Toggle alternate screen details view + if p.showingDetails { + p.exitDetailsView() + } else { + p.enterDetailsView() + } } } } @@ -460,12 +485,12 @@ func (p *Proxy) redrawEventsWithSelection() { term.Restore(int(os.Stdin.Fd()), p.rawModeState) } - // Move cursor up to redraw all events with correct selection indicators - // We need to move up (number of events + 1 for blank line + 1 for status line) lines + // Move cursor up to start of events section + // Count total lines: events + blank line + status line totalLines := len(p.eventHistory) + 2 fmt.Printf("\033[%dA", totalLines) - // Print all events with selection indicator, clearing each line first + // Print all events with selection indicator, clearing each line for i, event := range p.eventHistory { fmt.Printf("\033[2K") // Clear the entire current line if i == p.selectedEventIndex { @@ -958,91 +983,178 @@ func (p *Proxy) openBrowser(url string) error { return exec.Command(cmd, args...).Start() } -func (p *Proxy) showSelectedEventDetails() { - if len(p.eventHistory) == 0 || p.selectedEventIndex < 0 || p.selectedEventIndex >= len(p.eventHistory) { - color := ansi.Color(os.Stdout) - p.safePrintf("[%s] No event selected to show details for\n", - color.Yellow("WARN"), - ) +// enterDetailsView shows event details using less pager for scrolling +func (p *Proxy) enterDetailsView() { + if p.selectedEventIndex < 0 || p.selectedEventIndex >= len(p.eventHistory) { return } selectedEvent := p.eventHistory[p.selectedEventIndex] if selectedEvent.Data == nil { - color := ansi.Color(os.Stdout) - p.safePrintf("[%s] Selected event has no data to show details for\n", - color.Yellow("WARN"), - ) return } - webhookEvent := selectedEvent.Data - p.terminalMutex.Lock() - defer p.terminalMutex.Unlock() - // Temporarily restore normal terminal mode for printing + // Temporarily restore normal terminal mode if p.rawModeState != nil { term.Restore(int(os.Stdin.Fd()), p.rawModeState) } - // Clear the status line and the blank line above it - if p.statusLineShown { - fmt.Printf("\033[2A\033[K\033[1B\033[K") + // Build the details content + webhookEvent := selectedEvent.Data + color := ansi.Color(os.Stdout) + var content strings.Builder + + // Header with navigation hints + content.WriteString(ansi.Bold("Event Details")) + content.WriteString("\n") + content.WriteString(ansi.Faint("āŒØļø Press 'q' to return to events • Use arrow keys/Page Up/Down to scroll")) + content.WriteString("\n") + content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) + content.WriteString("\n\n") + + // Event metadata + timestampStr := selectedEvent.Time.Format(timeLayout) + statusIcon := color.Green("āœ“") + statusText := "succeeded" + if !selectedEvent.Success { + statusIcon = color.Red("āŒ") + statusText = "failed" } - // Print the event details with title - color := ansi.Color(os.Stdout) - fmt.Printf("│ %s %s%s\n", color.Bold(webhookEvent.Body.Request.Method), color.Bold(p.cfg.URL.String()), color.Bold(webhookEvent.Body.Path)) - fmt.Printf("│\n") + content.WriteString(fmt.Sprintf("%s Event %s with status %s at %s\n", statusIcon, statusText, color.Bold(fmt.Sprintf("%d", selectedEvent.Status)), ansi.Faint(timestampStr))) + content.WriteString("\n") - // Parse and display headers (no title) + // Dashboard URL + dashboardURL := p.cfg.DashboardBaseURL + if p.cfg.ProjectID != "" { + dashboardURL += "/cli/events/" + selectedEvent.ID + } + if p.cfg.ProjectMode == "console" { + dashboardURL = p.cfg.ConsoleBaseURL + } + content.WriteString(fmt.Sprintf("%s %s\n", ansi.Faint("šŸ”—"), ansi.Faint(dashboardURL))) + content.WriteString("\n") + content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) + content.WriteString("\n\n") + + // Request section + content.WriteString(ansi.Bold("Request")) + content.WriteString("\n\n") + content.WriteString(fmt.Sprintf("%s %s%s\n", color.Bold(webhookEvent.Body.Request.Method), p.cfg.URL.String(), webhookEvent.Body.Path)) + content.WriteString("\n") + content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) + content.WriteString("\n\n") + + // Headers section if len(webhookEvent.Body.Request.Headers) > 0 { + content.WriteString(ansi.Bold("Headers")) + content.WriteString("\n\n") + var headers map[string]json.RawMessage if err := json.Unmarshal(webhookEvent.Body.Request.Headers, &headers); err == nil { - // Get keys and sort them alphabetically keys := make([]string, 0, len(headers)) for key := range headers { keys = append(keys, key) } sort.Strings(keys) - // Print headers in alphabetical order for _, key := range keys { unquoted, _ := strconv.Unquote(string(headers[key])) - fmt.Printf("│ %s: %s\n", strings.ToLower(key), unquoted) + content.WriteString(fmt.Sprintf("%s: %s\n", ansi.Faint(strings.ToLower(key)), unquoted)) } } + content.WriteString("\n") + content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) + content.WriteString("\n\n") } - // Add blank line before body - fmt.Printf("│\n") - - // Display body (no title) + // Body section if len(webhookEvent.Body.Request.DataString) > 0 { - // Split body into lines and add left border to each line - bodyLines := strings.Split(webhookEvent.Body.Request.DataString, "\n") - for _, line := range bodyLines { - fmt.Printf("│ %s\n", line) + content.WriteString(ansi.Bold("Body")) + content.WriteString("\n\n") + + var bodyData interface{} + if err := json.Unmarshal([]byte(webhookEvent.Body.Request.DataString), &bodyData); err == nil { + prettyJSON, err := json.MarshalIndent(bodyData, "", " ") + if err == nil { + content.WriteString(string(prettyJSON)) + content.WriteString("\n") + } + } else { + content.WriteString(webhookEvent.Body.Request.DataString) + content.WriteString("\n") } } - // Restore the status line - fmt.Println() - var statusMsg string - if selectedEvent.Success { - statusMsg = fmt.Sprintf("> %s Selected event succeeded (%d) āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", - color.Green("āœ“"), selectedEvent.Status) - } else { - statusMsg = fmt.Sprintf("> %s Selected event failed (%d) āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", - color.Red("⨯"), selectedEvent.Status) + // Footer + content.WriteString("\n") + content.WriteString(fmt.Sprintf("%s Use arrow keys/Page Up/Down to scroll • Press 'q' to return to events\n", ansi.Faint("āŒØļø"))) + + // Set the flag before launching pager + p.showingDetails = true + + p.terminalMutex.Unlock() + + // Use less with standard options + // Note: Custom key bindings are unreliable, so we stick with 'q' to quit + // We use echo to pipe content to less, which allows less to read keyboard from terminal + + cmd := exec.Command("sh", "-c", "less -R") + + // Create stdin pipe to send content + stdinPipe, err := cmd.StdinPipe() + if err != nil { + // Fallback: print directly + p.terminalMutex.Lock() + fmt.Print(content.String()) + p.showingDetails = false + if p.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } + p.terminalMutex.Unlock() + return } - fmt.Printf("%s\n", statusMsg) + + // Connect to terminal + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Start less + if err := cmd.Start(); err != nil { + // Fallback: print directly + p.terminalMutex.Lock() + fmt.Print(content.String()) + p.showingDetails = false + if p.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } + p.terminalMutex.Unlock() + return + } + + // Write content to less + stdinPipe.Write([]byte(content.String())) + stdinPipe.Close() + + // Wait for less to exit + cmd.Wait() + + // After pager exits, restore state + p.terminalMutex.Lock() + p.showingDetails = false // Re-enable raw mode if p.rawModeState != nil { term.MakeRaw(int(os.Stdin.Fd())) } + p.terminalMutex.Unlock() +} + +// exitDetailsView is called when user presses 'd' or 'q' while in details view +func (p *Proxy) exitDetailsView() { + p.showingDetails = false } // From b8e50e55763283575e0ac2fb8d283ebbe41d82ce Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Fri, 3 Oct 2025 09:11:13 -0400 Subject: [PATCH 07/22] feat: Add new styling --- pkg/login/client_login.go | 2 +- pkg/proxy/proxy.go | 102 +++++++++++++++++++++++++++----------- 2 files changed, 75 insertions(+), 29 deletions(-) diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index 53766de..0bbb228 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -122,7 +122,7 @@ func GuestLogin(config *config.Config) (string, error) { BaseURL: parsedBaseURL, } - fmt.Println("🚩 Not connected with any account. Creating a guest account...") + fmt.Println("\n🚩 Not connected with any account. Creating a guest account...") guest_user, err := client.CreateGuestUser(hookdeck.CreateGuestUserInput{ DeviceName: config.DeviceName, diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index d637825..5b55c9f 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -528,7 +528,7 @@ func (p *Proxy) redrawEventsWithSelection() { // - Create a new CLI session // - Create a new websocket connection func (p *Proxy) Run(parentCtx context.Context) error { - const maxConnectAttempts = 3 + const maxConnectAttempts = 10 nAttempts := 0 // Track whether or not we have connected successfully. @@ -561,18 +561,76 @@ func (p *Proxy) Run(parentCtx context.Context) error { session, err := p.createSession(signalCtx) if err != nil { + // Stop spinner and restore terminal state before fatal error + p.terminalMutex.Lock() ansi.StopSpinner(s, "", p.cfg.Log.Out) + if p.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), p.rawModeState) + } + fmt.Print("\033[2K\r") + p.terminalMutex.Unlock() + p.cfg.Log.Fatalf("Error while authenticating with Hookdeck: %v", err) } if session.Id == "" { + // Stop spinner and restore terminal state before fatal error + p.terminalMutex.Lock() ansi.StopSpinner(s, "", p.cfg.Log.Out) + if p.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), p.rawModeState) + } + fmt.Print("\033[2K\r") + p.terminalMutex.Unlock() + p.cfg.Log.Fatalf("Error while starting a new session") } // Main loop to keep attempting to connect to Hookdeck once // we have created a session. for canConnect() { + // Apply backoff delay BEFORE creating new client (except for first attempt) + if nAttempts > 0 { + // For initial connection attempts (before first successful connection), + // use a fixed delay. After first connection, use exponential backoff. + var sleepDurationMS int + if hasConnectedOnce { + // Exponential backoff for reconnection attempts + attemptsOverMax := math.Max(0, float64(nAttempts-maxConnectAttempts)) + sleepDurationMS = int(math.Round(math.Min(100, math.Pow(attemptsOverMax, 2)) * 100)) + } else { + // Fixed 3 second delay between initial connection attempts + sleepDurationMS = 3000 + } + + log.WithField( + "prefix", "proxy.Proxy.Run", + ).Debugf( + "Connect backoff (%dms)", sleepDurationMS, + ) + + // Reset the timer to the next duration + p.connectionTimer.Stop() + p.connectionTimer.Reset(time.Duration(sleepDurationMS) * time.Millisecond) + + // Block with a spinner while waiting + ansi.StopSpinner(s, "", p.cfg.Log.Out) + // Use different message based on whether we've connected before + if hasConnectedOnce { + s = ansi.StartNewSpinner("Connection lost, reconnecting...", p.cfg.Log.Out) + } else { + s = ansi.StartNewSpinner("Connecting...", p.cfg.Log.Out) + } + select { + case <-p.connectionTimer.C: + // Continue to retry + case <-signalCtx.Done(): + p.connectionTimer.Stop() + ansi.StopSpinner(s, "", p.cfg.Log.Out) + return nil + } + } + p.webSocketClient = websocket.NewClient( p.cfg.WSBaseURL, session.Id, @@ -625,36 +683,24 @@ func (p *Proxy) Run(parentCtx context.Context) error { ansi.StopSpinner(s, "", p.cfg.Log.Out) return nil case <-p.webSocketClient.NotifyExpired: - if canConnect() { + if !canConnect() { + // Stop the spinner and restore terminal state before fatal error + p.terminalMutex.Lock() ansi.StopSpinner(s, "", p.cfg.Log.Out) - s = ansi.StartNewSpinner("Connection lost, reconnecting...", p.cfg.Log.Out) - } else { - p.cfg.Log.Fatalf("Session expired. Terminating after %d failed attempts to reauthorize", nAttempts) - } - } - - // Determine if we should backoff the connection retries. - attemptsOverMax := math.Max(0, float64(nAttempts-maxConnectAttempts)) - if canConnect() && attemptsOverMax > 0 { - // Determine the time to wait to reconnect, maximum of 10 second intervals - sleepDurationMS := int(math.Round(math.Min(100, math.Pow(attemptsOverMax, 2)) * 100)) - log.WithField( - "prefix", "proxy.Proxy.Run", - ).Debugf( - "Connect backoff (%dms)", sleepDurationMS, - ) - - // Reset the timer to the next duration - p.connectionTimer.Stop() - p.connectionTimer.Reset(time.Duration(sleepDurationMS) * time.Millisecond) + if p.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), p.rawModeState) + } + // Clear the spinner line + fmt.Print("\033[2K\r") + p.terminalMutex.Unlock() - // Block until the timer completes or we get interrupted by the user - select { - case <-p.connectionTimer.C: - case <-signalCtx.Done(): - p.connectionTimer.Stop() - return nil + // Print error without timestamp (use fmt instead of log to avoid formatter) + color := ansi.Color(os.Stdout) + fmt.Fprintf(os.Stderr, "%s Could not establish connection. Terminating after %d attempts to connect.\n", + color.Red("FATAL"), nAttempts) + os.Exit(1) } + // Connection lost, loop will retry (backoff happens at start of next iteration) } } From 36d52c9328a65622e33864d5397e732e4d7694c0 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Fri, 3 Oct 2025 12:12:59 -0400 Subject: [PATCH 08/22] feat: Add navigation for last 10 --- pkg/cmd/ci.go | 7 +- pkg/config/config.go | 2 + pkg/config/profile.go | 2 + pkg/listen/connection.go | 15 +- pkg/listen/listen.go | 4 + pkg/listen/printer.go | 18 +- pkg/listen/source.go | 18 ++ pkg/login/client_login.go | 2 + pkg/login/interactive_login.go | 1 + pkg/proxy/proxy.go | 455 +++++++++++++++++++++++---------- 10 files changed, 372 insertions(+), 152 deletions(-) diff --git a/pkg/cmd/ci.go b/pkg/cmd/ci.go index 0839831..6bc0bb9 100644 --- a/pkg/cmd/ci.go +++ b/pkg/cmd/ci.go @@ -1,7 +1,7 @@ package cmd import ( - "log" + "fmt" "os" "github.com/spf13/cobra" @@ -35,7 +35,10 @@ func newCICmd() *ciCmd { func (lc *ciCmd) runCICmd(cmd *cobra.Command, args []string) error { err := validators.APIKey(lc.apiKey) if err != nil { - log.Fatal(err) + if err == validators.ErrAPIKeyNotConfigured { + return fmt.Errorf("Provide a project API key using the --api-key flag. Example: hookdeck ci --api-key YOUR_KEY") + } + return err } return login.CILogin(&Config, lc.apiKey, lc.name) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 106c142..8fff86c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -242,6 +242,8 @@ func (c *Config) constructConfig() { c.Profile.ProjectId = stringCoalesce(c.Profile.ProjectId, c.viper.GetString(c.Profile.getConfigField("project_id")), c.viper.GetString("project_id"), c.viper.GetString(c.Profile.getConfigField("workspace_id")), c.viper.GetString(c.Profile.getConfigField("team_id")), c.viper.GetString("workspace_id"), "") c.Profile.ProjectMode = stringCoalesce(c.Profile.ProjectMode, c.viper.GetString(c.Profile.getConfigField("project_mode")), c.viper.GetString("project_mode"), c.viper.GetString(c.Profile.getConfigField("workspace_mode")), c.viper.GetString(c.Profile.getConfigField("team_mode")), c.viper.GetString("workspace_mode"), "") + + c.Profile.GuestURL = stringCoalesce(c.Profile.GuestURL, c.viper.GetString(c.Profile.getConfigField("guest_url")), c.viper.GetString("guest_url"), "") } // getConfigPath returns the path for the config file. diff --git a/pkg/config/profile.go b/pkg/config/profile.go index 487a34b..77c9142 100644 --- a/pkg/config/profile.go +++ b/pkg/config/profile.go @@ -9,6 +9,7 @@ type Profile struct { APIKey string ProjectId string ProjectMode string + GuestURL string // URL to create permanent account for guest users Config *Config } @@ -22,6 +23,7 @@ func (p *Profile) SaveProfile() error { p.Config.viper.Set(p.getConfigField("api_key"), p.APIKey) p.Config.viper.Set(p.getConfigField("project_id"), p.ProjectId) p.Config.viper.Set(p.getConfigField("project_mode"), p.ProjectMode) + p.Config.viper.Set(p.getConfigField("guest_url"), p.GuestURL) return p.Config.writeConfig() } diff --git a/pkg/listen/connection.go b/pkg/listen/connection.go index c459ca4..c213f1d 100644 --- a/pkg/listen/connection.go +++ b/pkg/listen/connection.go @@ -76,6 +76,11 @@ func ensureConnections(client *hookdeckclient.Client, connections []*hookdecksdk return connections, nil } + // If a connection filter was specified and no match found, don't auto-create + if connectionFilterString != "" { + return connections, fmt.Errorf("no connection found matching filter \"%s\" for source \"%s\"", connectionFilterString, sources[0].Name) + } + log.Debug(fmt.Sprintf("No connection found. Creating a connection for Source \"%s\", Connection \"%s\", and path \"%s\"", sources[0].Name, connectionFilterString, path)) connectionDetails := struct { @@ -85,12 +90,7 @@ func ensureConnections(client *hookdeckclient.Client, connections []*hookdecksdk }{} connectionDetails.DestinationName = fmt.Sprintf("%s-%s", "cli", sources[0].Name) - - if len(connectionFilterString) == 0 { - connectionDetails.ConnectionName = fmt.Sprintf("%s_to_%s", sources[0].Name, connectionDetails.DestinationName) - } else { - connectionDetails.ConnectionName = connectionFilterString - } + connectionDetails.ConnectionName = connectionDetails.DestinationName // Use same name as destination if len(path) == 0 { connectionDetails.Path = "/" @@ -98,6 +98,9 @@ func ensureConnections(client *hookdeckclient.Client, connections []*hookdecksdk connectionDetails.Path = path } + // Print message to user about creating the connection + fmt.Printf("\nThere's no CLI destination connected to %s, creating one named %s\n", sources[0].Name, connectionDetails.DestinationName) + connection, err := client.Connection.Create(context.Background(), &hookdecksdk.ConnectionCreateRequest{ Name: hookdecksdk.OptionalOrNull(&connectionDetails.ConnectionName), SourceId: hookdecksdk.OptionalOrNull(&sources[0].Id), diff --git a/pkg/listen/listen.go b/pkg/listen/listen.go index bec9118..6a2c055 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -67,7 +67,11 @@ func Listen(URL *url.URL, sourceQuery string, connectionFilterString string, fla if guestURL == "" { return err } + } else if config.Profile.GuestURL != "" && config.Profile.APIKey != "" { + // User is logged in with a guest account (has both GuestURL and APIKey) + guestURL = config.Profile.GuestURL } + // If user has permanent account (APIKey but no GuestURL), guestURL remains empty sdkClient := config.GetClient() diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index ad236c8..aad3615 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -3,6 +3,7 @@ package listen import ( "fmt" "net/url" + "strings" "github.com/hookdeck/hookdeck-cli/pkg/ansi" "github.com/hookdeck/hookdeck-cli/pkg/config" @@ -50,12 +51,25 @@ func printSourcesWithConnections(config *config.Config, sources []*hookdecksdk.S for j, connection := range sourceConns { fullPath := targetURL.Scheme + "://" + targetURL.Host + *connection.Destination.CliPath + // Get connection name from FullName (format: "source -> destination") + // Split on "->" and take the second part (destination) + connNameDisplay := "" + if connection.FullName != nil && *connection.FullName != "" { + parts := strings.Split(*connection.FullName, "->") + if len(parts) == 2 { + destinationName := strings.TrimSpace(parts[1]) + if destinationName != "" { + connNameDisplay = " " + ansi.Faint(fmt.Sprintf("(%s)", destinationName)) + } + } + } + if j == numConns-1 { // Last connection - use └─ - fmt.Printf("└─ Forwards to → %s %s\n", fullPath, ansi.Faint(fmt.Sprintf("(%s)", *connection.Name))) + fmt.Printf("└─ Forwards to → %s%s\n", fullPath, connNameDisplay) } else { // Not last connection - use ā”œā”€ - fmt.Printf("ā”œā”€ Forwards to → %s %s\n", fullPath, ansi.Faint(fmt.Sprintf("(%s)", *connection.Name))) + fmt.Printf("ā”œā”€ Forwards to → %s%s\n", fullPath, connNameDisplay) } } } else { diff --git a/pkg/listen/source.go b/pkg/listen/source.go index 4e8797d..67b7814 100644 --- a/pkg/listen/source.go +++ b/pkg/listen/source.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "github.com/AlecAivazis/survey/v2" "github.com/hookdeck/hookdeck-cli/pkg/slug" @@ -59,6 +60,23 @@ func getSources(sdkClient *hookdeckclient.Client, sourceQuery []string) ([]*hook return validateSources(searchedSources) } + // Source not found, ask user if they want to create it + fmt.Printf("\nSource \"%s\" not found.\n", sourceQuery[0]) + + createConfirm := false + prompt := &survey.Confirm{ + Message: fmt.Sprintf("Do you want to create a new source named \"%s\"?", sourceQuery[0]), + } + err = survey.AskOne(prompt, &createConfirm) + if err != nil { + return []*hookdecksdk.Source{}, err + } + + if !createConfirm { + // User declined to create source, exit cleanly without error message + os.Exit(0) + } + // Create source with provided name source, err := createSource(sdkClient, &sourceQuery[0]) if err != nil { diff --git a/pkg/login/client_login.go b/pkg/login/client_login.go index 0bbb228..d1c913f 100644 --- a/pkg/login/client_login.go +++ b/pkg/login/client_login.go @@ -98,6 +98,7 @@ func Login(config *config.Config, input io.Reader) error { config.Profile.APIKey = response.APIKey config.Profile.ProjectId = response.ProjectID config.Profile.ProjectMode = response.ProjectMode + config.Profile.GuestURL = "" // Clear guest URL when logging in with permanent account if err = config.Profile.SaveProfile(); err != nil { return err @@ -144,6 +145,7 @@ func GuestLogin(config *config.Config) (string, error) { config.Profile.APIKey = response.APIKey config.Profile.ProjectId = response.ProjectID config.Profile.ProjectMode = response.ProjectMode + config.Profile.GuestURL = guest_user.Url if err = config.Profile.SaveProfile(); err != nil { return "", err diff --git a/pkg/login/interactive_login.go b/pkg/login/interactive_login.go index d5a53b5..3893e14 100644 --- a/pkg/login/interactive_login.go +++ b/pkg/login/interactive_login.go @@ -65,6 +65,7 @@ func InteractiveLogin(config *config.Config) error { config.Profile.APIKey = response.APIKey config.Profile.ProjectMode = response.ProjectMode config.Profile.ProjectId = response.ProjectID + config.Profile.GuestURL = "" // Clear guest URL when logging in with permanent account if err = config.Profile.SaveProfile(); err != nil { ansi.StopSpinner(s, "", os.Stdout) diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 5b55c9f..648b93f 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -31,6 +31,8 @@ import ( ) const timeLayout = "2006-01-02 15:04:05" +const maxHistorySize = 50 // Maximum events to keep in memory +const maxNavigableEvents = 10 // Only last 10 events are navigable // // Public types @@ -85,14 +87,17 @@ type Proxy struct { terminalMutex sync.Mutex rawModeState *term.State // Event navigation - eventHistory []EventInfo - selectedEventIndex int - userNavigated bool // Track if user has manually navigated away from latest event + eventHistory []EventInfo + selectedEventIndex int + userNavigated bool // Track if user has manually navigated away from latest event + eventsTitleDisplayed bool // Track if "Events" title has been displayed // Waiting animation waitingAnimationFrame int stopWaitingAnimation chan bool // Details view showingDetails bool // Track if we're in alternate screen showing details + // Connection state + isConnected bool // Track if we're currently connected (disable actions during reconnection) } func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { @@ -134,6 +139,127 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { p.terminalMutex.Lock() defer p.terminalMutex.Unlock() + // Check if this is the 11th event - need to add "Events" title before the first historical event + isEleventhEvent := len(p.eventHistory) == maxNavigableEvents && !p.eventsTitleDisplayed + + // If this is the 11th event, print the "Events" title now (before adding the event) + if isEleventhEvent { + // Temporarily restore normal terminal mode for printing + if p.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), p.rawModeState) + } + + // Move up to clear status line and blank line + fmt.Print("\033[2A\033[2K\r\033[1B\033[2K\r\033[1A") + + // Print "Events" title with newline above + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n\n", color.Faint("Events")) + + // Print blank line and status that will be replaced + fmt.Println() + statusMsg := fmt.Sprintf("%s Adding...", color.Faint("ā—")) + fmt.Printf("%s\n", statusMsg) + + p.eventsTitleDisplayed = true + + // Re-enable raw mode + if p.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } + } + + // Check if any event will exit the navigable window when we add this new event + // We need to remove indentation from events becoming immutable + needToRedrawForExitingEvents := false + if len(p.eventHistory) >= maxNavigableEvents { + needToRedrawForExitingEvents = true + } + + // Also check if the current selection will be pushed out + needToClearOldSelection := false + if p.userNavigated && len(p.eventHistory) > 0 { + // Calculate what the navigable range will be after adding the new event + futureHistorySize := len(p.eventHistory) + 1 + futureNavigableStartIdx := futureHistorySize - maxNavigableEvents + if futureNavigableStartIdx < 0 { + futureNavigableStartIdx = 0 + } + + // If current selection will be outside future navigable range + if p.selectedEventIndex < futureNavigableStartIdx { + needToClearOldSelection = true + } + } + + // Redraw navigable window if events are exiting or selection is being cleared + // BUT skip if we just printed the Events title (11th event case) + if (needToRedrawForExitingEvents || needToClearOldSelection) && !isEleventhEvent { + // Temporarily restore normal terminal mode for printing + if p.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), p.rawModeState) + } + + // Calculate current navigable window + currentNavigableStartIdx := len(p.eventHistory) - maxNavigableEvents + if currentNavigableStartIdx < 0 { + currentNavigableStartIdx = 0 + } + currentNumNavigableEvents := len(p.eventHistory) - currentNavigableStartIdx + + // Calculate future navigable window to determine which event will become immutable + futureHistorySize := len(p.eventHistory) + 1 + futureNavigableStartIdx := futureHistorySize - maxNavigableEvents + if futureNavigableStartIdx < 0 { + futureNavigableStartIdx = 0 + } + + // Move cursor up and clear + // Account for: navigable events + separator (3 lines if present) + blank + status + linesToMoveUp := currentNumNavigableEvents + 2 // events + blank + status + // If we'll have a separator, add 3 more lines (blank line + "Recent events" + blank line) + if futureNavigableStartIdx > 0 { + linesToMoveUp += 3 + } + fmt.Printf("\033[%dA", linesToMoveUp) + fmt.Print("\033[J") + + // NOTE: We NEVER redraw the "Events" title - it was printed once and stays permanent + + // Redraw events + for i := currentNavigableStartIdx; i < len(p.eventHistory); i++ { + // Events that will become immutable (fall outside future navigable range) have no indentation + if i < futureNavigableStartIdx { + fmt.Printf("%s\n", p.eventHistory[i].LogLine) // No indentation + } else { + // Add "Latest events" separator before first navigable event + if i == futureNavigableStartIdx { + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n\n", color.Faint("Latest events (↑↓ to navigate)")) // Extra newline after separator + } + // Only indent selected event with ">", others have no indentation + if i == p.selectedEventIndex { + fmt.Printf("> %s\n", p.eventHistory[i].LogLine) // Selected + } else { + fmt.Printf("%s\n", p.eventHistory[i].LogLine) // No indentation + } + } + } + + // Blank line + fmt.Println() + + // Status message (will be replaced soon) + color := ansi.Color(os.Stdout) + statusMsg := fmt.Sprintf("%s Updating...", color.Faint("ā—")) + fmt.Printf("%s\n", statusMsg) + + // Re-enable raw mode + if p.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } + } + // Add event to history eventInfo := EventInfo{ ID: p.latestEventID, @@ -145,9 +271,37 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { } p.eventHistory = append(p.eventHistory, eventInfo) + // Limit history to last 50 events - trim old ones + if len(p.eventHistory) > maxHistorySize { + // Remove oldest event + removedCount := len(p.eventHistory) - maxHistorySize + p.eventHistory = p.eventHistory[removedCount:] + + // Adjust selected index if it was pointing to a removed event + if p.selectedEventIndex < removedCount { + p.selectedEventIndex = 0 + p.userNavigated = false // Reset navigation since selected event was removed + } else { + p.selectedEventIndex -= removedCount + } + } + // Auto-select the latest event unless user has navigated away + selectionAdjusted := false if !p.userNavigated { p.selectedEventIndex = len(p.eventHistory) - 1 + } else { + // If user has navigated, check if selected event is still in navigable range + navigableStartIdx := len(p.eventHistory) - maxNavigableEvents + if navigableStartIdx < 0 { + navigableStartIdx = 0 + } + + // If selected event is now outside navigable range, move to oldest navigable event + if p.selectedEventIndex < navigableStartIdx { + p.selectedEventIndex = navigableStartIdx + selectionAdjusted = true // Need to redraw to show new selection + } } // Temporarily restore normal terminal mode for printing @@ -155,83 +309,68 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { term.Restore(int(os.Stdin.Fd()), p.rawModeState) } - // If we have multiple events and auto-selection is enabled, redraw all events - if len(p.eventHistory) > 1 && !p.userNavigated { - // Move cursor up to the first event line - // From current position (after cursor), we need to move up: - // - 1 line for previous status - // - 1 line for blank line - // - (len(p.eventHistory) - 1) lines for all previous events - if p.statusLineShown { - linesToMoveUp := 1 + 1 + (len(p.eventHistory) - 1) - fmt.Printf("\033[%dA", linesToMoveUp) - } + // Calculate the navigable window (last 10 events) + navigableStartIdx := len(p.eventHistory) - maxNavigableEvents + if navigableStartIdx < 0 { + navigableStartIdx = 0 + } + numNavigableEvents := len(p.eventHistory) - navigableStartIdx + + // If we have multiple navigable events and auto-selecting, redraw navigable window + // Also redraw if selection was adjusted + if numNavigableEvents > 1 && (!p.userNavigated || selectionAdjusted) { + // Move cursor up to first navigable event and clear everything + linesToMoveUp := numNavigableEvents - 1 + 2 // previous navigable events + blank + status + fmt.Printf("\033[%dA", linesToMoveUp) + fmt.Print("\033[J") - // Print all events with selection indicator, clearing each line - for i, event := range p.eventHistory { - fmt.Printf("\033[2K") // Clear the entire current line + // Print navigable events with selection on the latest + for i := navigableStartIdx; i < len(p.eventHistory); i++ { if i == p.selectedEventIndex { - fmt.Printf("> %s\n", event.LogLine) + fmt.Printf("> %s\n", p.eventHistory[i].LogLine) } else { - fmt.Printf(" %s\n", event.LogLine) + fmt.Printf("%s\n", p.eventHistory[i].LogLine) // No indentation } } - - // Add a newline before the status line (clear the line first) - fmt.Printf("\033[2K\n") - - // Generate and print the new status message - var statusMsg string - color := ansi.Color(os.Stdout) - if p.latestEventSuccess { - statusMsg = fmt.Sprintf("> %s Last event succeeded with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", - color.Green("āœ“"), p.latestEventStatus) - } else { - statusMsg = fmt.Sprintf("> %s Last event failed with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", - color.Red("x").Bold(), p.latestEventStatus) - } - - fmt.Printf("\033[2K%s\n", statusMsg) - p.statusLineShown = true - - // Re-enable raw mode - if p.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) + } else { + // First event or user has navigated - simple append + if p.statusLineShown { + if len(p.eventHistory) == 1 { + // First event - only clear the "waiting" status line + fmt.Print("\033[1A\033[2K\r") + } else { + // Clear status line and blank line + fmt.Print("\033[2A\033[2K\r\033[1B\033[2K\r\033[1A") + } } - return - } - // First event or user has navigated - simple case - if p.statusLineShown { - if len(p.eventHistory) == 1 { - // First event - only clear the status line (cursor is already at the right position) - fmt.Printf("\033[1A\033[K") + // Print the new event + newEventIndex := len(p.eventHistory) - 1 + // Only indent if selected, otherwise no indentation + if p.selectedEventIndex == newEventIndex { + fmt.Printf("> %s\n", p.eventHistory[newEventIndex].LogLine) } else { - // Subsequent events - clear the status line and blank line above it, then move back to the new event position - fmt.Printf("\033[2A\033[K\033[1B\033[K\033[1A") + fmt.Printf("%s\n", p.eventHistory[newEventIndex].LogLine) // No indentation } } - // Print the event log with selection indicator - newEventIndex := len(p.eventHistory) - 1 - if p.selectedEventIndex == newEventIndex { - fmt.Printf("> %s\n", p.eventHistory[newEventIndex].LogLine) - } else { - fmt.Printf(" %s\n", p.eventHistory[newEventIndex].LogLine) - } - - // Add a newline before the status line + // Blank line fmt.Println() - // Generate and print the new status message + // Generate status message var statusMsg string color := ansi.Color(os.Stdout) if p.latestEventSuccess { statusMsg = fmt.Sprintf("> %s Last event succeeded with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", color.Green("āœ“"), p.latestEventStatus) } else { - statusMsg = fmt.Sprintf("> %s Last event failed with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", - color.Red("x").Bold(), p.latestEventStatus) + if p.latestEventStatus == 0 { + statusMsg = fmt.Sprintf("> %s Last event failed with error āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Red("x").Bold()) + } else { + statusMsg = fmt.Sprintf("> %s Last event failed with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Red("x").Bold(), p.latestEventStatus) + } } fmt.Printf("%s\n", statusMsg) @@ -271,37 +410,30 @@ func (p *Proxy) updateStatusLine() { p.terminalMutex.Lock() defer p.terminalMutex.Unlock() + // Only update if we haven't received any events yet (just the waiting animation) + if p.hasReceivedEvent { + return + } + // Temporarily restore normal terminal mode for printing if p.rawModeState != nil { term.Restore(int(os.Stdin.Fd()), p.rawModeState) } - var statusMsg string - if !p.hasReceivedEvent { - // Animated green dot (alternates between ā— and ā—‹) - color := ansi.Color(os.Stdout) - var dot string - if p.waitingAnimationFrame%2 == 0 { - dot = fmt.Sprintf("%s", color.Green("ā—")) - } else { - dot = fmt.Sprintf("%s", color.Green("ā—‹")) - } - p.waitingAnimationFrame++ - statusMsg = fmt.Sprintf("%s Connected. Waiting for events...", dot) + // Animated green dot (alternates between ā— and ā—‹) + color := ansi.Color(os.Stdout) + var dot string + if p.waitingAnimationFrame%2 == 0 { + dot = fmt.Sprintf("%s", color.Green("ā—")) } else { - color := ansi.Color(os.Stdout) - if p.latestEventSuccess { - statusMsg = fmt.Sprintf("> %s Last event succeeded (%d) āŒØļø [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", - color.Green("āœ“"), p.latestEventStatus) - } else { - statusMsg = fmt.Sprintf("> %s Last event failed (%d) āŒØļø [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", - color.Red("x").Bold(), p.latestEventStatus) - } + dot = fmt.Sprintf("%s", color.Green("ā—‹")) } + p.waitingAnimationFrame++ + statusMsg := fmt.Sprintf("%s Connected. Waiting for events...", dot) if p.statusLineShown { // If we've shown a status before, move up one line and clear it - fmt.Printf("\033[1A\033[K%s\n", statusMsg) + fmt.Printf("\033[1A\033[2K\r%s\n", statusMsg) } else { // First time showing status fmt.Printf("%s\n", statusMsg) @@ -404,8 +536,8 @@ func (p *Proxy) processKeyboardInput(input []byte) { } } - // Disable all other shortcuts until first event is received - if !p.hasReceivedEvent { + // Disable all other shortcuts until first event is received or while not connected + if !p.hasReceivedEvent || !p.isConnected { return } @@ -445,15 +577,23 @@ func (p *Proxy) processKeyboardInput(input []byte) { } } -// navigateEvents moves the selection up or down in the event history +// navigateEvents moves the selection up or down in the event history (only within last 10 events) func (p *Proxy) navigateEvents(direction int) { if len(p.eventHistory) == 0 { return } + // Calculate the navigable window (last 10 events) + navigableStartIdx := len(p.eventHistory) - maxNavigableEvents + if navigableStartIdx < 0 { + navigableStartIdx = 0 + } + newIndex := p.selectedEventIndex + direction - if newIndex < 0 { - newIndex = 0 + + // Clamp to navigable range + if newIndex < navigableStartIdx { + newIndex = navigableStartIdx } else if newIndex >= len(p.eventHistory) { newIndex = len(p.eventHistory) - 1 } @@ -471,7 +611,7 @@ func (p *Proxy) navigateEvents(direction int) { } } -// redrawEventsWithSelection updates the selection indicators without clearing the screen +// redrawEventsWithSelection updates the selection indicators without clearing the screen (only last 10 events) func (p *Proxy) redrawEventsWithSelection() { if len(p.eventHistory) == 0 { return @@ -485,36 +625,60 @@ func (p *Proxy) redrawEventsWithSelection() { term.Restore(int(os.Stdin.Fd()), p.rawModeState) } - // Move cursor up to start of events section - // Count total lines: events + blank line + status line - totalLines := len(p.eventHistory) + 2 - fmt.Printf("\033[%dA", totalLines) + // Calculate the navigable window (last 10 events) + navigableStartIdx := len(p.eventHistory) - maxNavigableEvents + if navigableStartIdx < 0 { + navigableStartIdx = 0 + } + numNavigableEvents := len(p.eventHistory) - navigableStartIdx + + // Move cursor up to start of navigable events and clear everything below + linesToMoveUp := numNavigableEvents + 2 // navigable events + blank + status + // If we have a separator, add 3 more lines (blank line + "Recent events" + blank line) + if navigableStartIdx > 0 { + linesToMoveUp += 3 + } + fmt.Printf("\033[%dA", linesToMoveUp) + fmt.Print("\033[J") + + // NOTE: We NEVER redraw the "Events" title - it was printed once and stays permanent + + // Add separator if there are historical events + if navigableStartIdx > 0 { + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n\n", color.Faint("Latest events (↑↓ to navigate)")) // Extra newline after separator + } - // Print all events with selection indicator, clearing each line - for i, event := range p.eventHistory { - fmt.Printf("\033[2K") // Clear the entire current line + // Print only the navigable events with selection indicator + for i := navigableStartIdx; i < len(p.eventHistory); i++ { if i == p.selectedEventIndex { - fmt.Printf("> %s\n", event.LogLine) + fmt.Printf("> %s\n", p.eventHistory[i].LogLine) // Selected event with > } else { - fmt.Printf(" %s\n", event.LogLine) + fmt.Printf("%s\n", p.eventHistory[i].LogLine) // No indentation } } // Add a newline before the status line - fmt.Printf("\033[2K\n") // Clear entire line and add newline + fmt.Println() // Generate and print the status message for the selected event var statusMsg string color := ansi.Color(os.Stdout) - if p.eventHistory[p.selectedEventIndex].Success { + selectedEvent := p.eventHistory[p.selectedEventIndex] + if selectedEvent.Success { statusMsg = fmt.Sprintf("> %s Selected event succeeded with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", - color.Green("āœ“"), p.eventHistory[p.selectedEventIndex].Status) + color.Green("āœ“"), selectedEvent.Status) } else { - statusMsg = fmt.Sprintf("> %s Selected event failed with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", - color.Red("⨯"), p.eventHistory[p.selectedEventIndex].Status) + if selectedEvent.Status == 0 { + statusMsg = fmt.Sprintf("> %s Selected event failed with error āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Red("x").Bold()) + } else { + statusMsg = fmt.Sprintf("> %s Selected event failed with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Red("x").Bold(), selectedEvent.Status) + } } - fmt.Printf("\033[2K%s\n", statusMsg) // Clear entire line and print status + fmt.Printf("%s\n", statusMsg) p.statusLineShown = true // Re-enable raw mode @@ -529,18 +693,17 @@ func (p *Proxy) redrawEventsWithSelection() { // - Create a new websocket connection func (p *Proxy) Run(parentCtx context.Context) error { const maxConnectAttempts = 10 + const maxReconnectAttempts = 10 // Also limit reconnection attempts nAttempts := 0 // Track whether or not we have connected successfully. - // Once we have connected we no longer limit the number - // of connection attempts that will be made and will retry - // until the connection is successful or the user terminates - // the program. hasConnectedOnce := false canConnect := func() bool { if hasConnectedOnce { - return true + // After first successful connection, allow limited reconnection attempts + return nAttempts < maxReconnectAttempts } else { + // Initial connection attempts return nAttempts < maxConnectAttempts } } @@ -591,17 +754,10 @@ func (p *Proxy) Run(parentCtx context.Context) error { for canConnect() { // Apply backoff delay BEFORE creating new client (except for first attempt) if nAttempts > 0 { - // For initial connection attempts (before first successful connection), - // use a fixed delay. After first connection, use exponential backoff. - var sleepDurationMS int - if hasConnectedOnce { - // Exponential backoff for reconnection attempts - attemptsOverMax := math.Max(0, float64(nAttempts-maxConnectAttempts)) - sleepDurationMS = int(math.Round(math.Min(100, math.Pow(attemptsOverMax, 2)) * 100)) - } else { - // Fixed 3 second delay between initial connection attempts - sleepDurationMS = 3000 - } + // Exponential backoff: 100ms * 2^(attempt-1), capped at 30 seconds + // Attempt 1: 100ms, 2: 200ms, 3: 400ms, 4: 800ms, 5: 1.6s, 6: 3.2s, 7: 6.4s, 8: 12.8s, 9+: 30s + backoffMS := math.Min(100*math.Pow(2, float64(nAttempts-1)), 30000) + sleepDurationMS := int(backoffMS) log.WithField( "prefix", "proxy.Proxy.Run", @@ -613,6 +769,15 @@ func (p *Proxy) Run(parentCtx context.Context) error { p.connectionTimer.Stop() p.connectionTimer.Reset(time.Duration(sleepDurationMS) * time.Millisecond) + // Clear the status line before showing reconnection spinner + p.terminalMutex.Lock() + if p.statusLineShown { + // Move up and clear the status line + fmt.Print("\033[1A\033[2K\r") + p.statusLineShown = false + } + p.terminalMutex.Unlock() + // Block with a spinner while waiting ansi.StopSpinner(s, "", p.cfg.Log.Out) // Use different message based on whether we've connected before @@ -646,28 +811,27 @@ func (p *Proxy) Run(parentCtx context.Context) error { // Monitor the websocket for connection and update the spinner appropriately. go func() { <-p.webSocketClient.Connected() - if hasConnectedOnce { - // Stop the spinner and print the message safely - p.terminalMutex.Lock() - if p.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), p.rawModeState) - } - ansi.StopSpinner(s, "Reconnected!", p.cfg.Log.Out) - if p.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } - p.terminalMutex.Unlock() + // Mark as connected and reset attempt counter + p.isConnected = true + nAttempts = 0 + + // Stop the spinner and update status line + p.terminalMutex.Lock() + if p.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), p.rawModeState) + } + ansi.StopSpinner(s, "", p.cfg.Log.Out) + if p.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } + p.terminalMutex.Unlock() + + // Always update the status line to show current state + if hasConnectedOnce || p.hasReceivedEvent { + // If we've reconnected or have events, just update the status + p.updateStatusLine() } else { - // Stop the spinner without a message and use our status line - p.terminalMutex.Lock() - if p.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), p.rawModeState) - } - ansi.StopSpinner(s, "", p.cfg.Log.Out) - if p.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } - p.terminalMutex.Unlock() + // First connection, show initial status p.updateStatusLine() } hasConnectedOnce = true @@ -683,6 +847,9 @@ func (p *Proxy) Run(parentCtx context.Context) error { ansi.StopSpinner(s, "", p.cfg.Log.Out) return nil case <-p.webSocketClient.NotifyExpired: + // Mark as disconnected + p.isConnected = false + if !canConnect() { // Stop the spinner and restore terminal state before fatal error p.terminalMutex.Lock() @@ -1064,12 +1231,16 @@ func (p *Proxy) enterDetailsView() { timestampStr := selectedEvent.Time.Format(timeLayout) statusIcon := color.Green("āœ“") statusText := "succeeded" + statusDisplay := color.Bold(fmt.Sprintf("%d", selectedEvent.Status)) if !selectedEvent.Success { - statusIcon = color.Red("āŒ") + statusIcon = color.Red("x").Bold() statusText = "failed" + if selectedEvent.Status == 0 { + statusDisplay = color.Bold("error") + } } - content.WriteString(fmt.Sprintf("%s Event %s with status %s at %s\n", statusIcon, statusText, color.Bold(fmt.Sprintf("%d", selectedEvent.Status)), ansi.Faint(timestampStr))) + content.WriteString(fmt.Sprintf("%s Event %s with status %s at %s\n", statusIcon, statusText, statusDisplay, ansi.Faint(timestampStr))) content.WriteString("\n") // Dashboard URL From ebc5cfebf61f5bfcb0a59cadc9033041493378d2 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Fri, 3 Oct 2025 12:20:41 -0400 Subject: [PATCH 09/22] fix: Pin the selected event --- pkg/proxy/proxy.go | 165 +++++++++++++++++++++++++++++++-------------- 1 file changed, 116 insertions(+), 49 deletions(-) diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 648b93f..c5f6f15 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -176,7 +176,7 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { needToRedrawForExitingEvents = true } - // Also check if the current selection will be pushed out + // Check if we need to redraw due to selection changes needToClearOldSelection := false if p.userNavigated && len(p.eventHistory) > 0 { // Calculate what the navigable range will be after adding the new event @@ -186,7 +186,8 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { futureNavigableStartIdx = 0 } - // If current selection will be outside future navigable range + // If current selection will be outside future navigable range, we need to redraw + // (The selected event will be pinned in the display, breaking chronological order) if p.selectedEventIndex < futureNavigableStartIdx { needToClearOldSelection = true } @@ -287,22 +288,11 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { } // Auto-select the latest event unless user has navigated away - selectionAdjusted := false if !p.userNavigated { p.selectedEventIndex = len(p.eventHistory) - 1 - } else { - // If user has navigated, check if selected event is still in navigable range - navigableStartIdx := len(p.eventHistory) - maxNavigableEvents - if navigableStartIdx < 0 { - navigableStartIdx = 0 - } - - // If selected event is now outside navigable range, move to oldest navigable event - if p.selectedEventIndex < navigableStartIdx { - p.selectedEventIndex = navigableStartIdx - selectionAdjusted = true // Need to redraw to show new selection - } } + // Note: If user has navigated, we DON'T change selectedEventIndex + // The display logic will handle showing it even if it's outside the normal navigable range // Temporarily restore normal terminal mode for printing if p.rawModeState != nil { @@ -317,8 +307,9 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { numNavigableEvents := len(p.eventHistory) - navigableStartIdx // If we have multiple navigable events and auto-selecting, redraw navigable window - // Also redraw if selection was adjusted - if numNavigableEvents > 1 && (!p.userNavigated || selectionAdjusted) { + // Also redraw if user has navigated (to show pinned selection) + if numNavigableEvents > 1 && !p.userNavigated { + // Auto-selecting mode: redraw to move selection to latest // Move cursor up to first navigable event and clear everything linesToMoveUp := numNavigableEvents - 1 + 2 // previous navigable events + blank + status fmt.Printf("\033[%dA", linesToMoveUp) @@ -332,8 +323,26 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { fmt.Printf("%s\n", p.eventHistory[i].LogLine) // No indentation } } + } else if p.userNavigated && numNavigableEvents > 1 { + // User has navigated: redraw to show pinned selected event + // Get the navigable events (includes pinned selected event if applicable) + navigableIndices := p.getNavigableEvents() + + // Move cursor up and redraw all navigable events + linesToMoveUp := len(navigableIndices) - 1 + 2 // previous navigable events + blank + status + fmt.Printf("\033[%dA", linesToMoveUp) + fmt.Print("\033[J") + + // Print navigable events (including pinned event) with selection indicator + for _, idx := range navigableIndices { + if idx == p.selectedEventIndex { + fmt.Printf("> %s\n", p.eventHistory[idx].LogLine) + } else { + fmt.Printf("%s\n", p.eventHistory[idx].LogLine) + } + } } else { - // First event or user has navigated - simple append + // First event - simple append if p.statusLineShown { if len(p.eventHistory) == 1 { // First event - only clear the "waiting" status line @@ -361,14 +370,14 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { var statusMsg string color := ansi.Color(os.Stdout) if p.latestEventSuccess { - statusMsg = fmt.Sprintf("> %s Last event succeeded with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Last event succeeded with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", color.Green("āœ“"), p.latestEventStatus) } else { if p.latestEventStatus == 0 { - statusMsg = fmt.Sprintf("> %s Last event failed with error āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Last event failed with error āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", color.Red("x").Bold()) } else { - statusMsg = fmt.Sprintf("> %s Last event failed with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Last event failed with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", color.Red("x").Bold(), p.latestEventStatus) } } @@ -577,33 +586,88 @@ func (p *Proxy) processKeyboardInput(input []byte) { } } -// navigateEvents moves the selection up or down in the event history (only within last 10 events) +// getNavigableEvents returns the indices of events that should be shown in the "Latest events" section +// This includes the last (maxNavigableEvents-1) chronological events, plus the selected event if it's outside this range +func (p *Proxy) getNavigableEvents() []int { + historySize := len(p.eventHistory) + + // Calculate the normal navigable range (last 10 events) + normalStartIdx := historySize - maxNavigableEvents + if normalStartIdx < 0 { + normalStartIdx = 0 + } + + // If user hasn't navigated or selected event is within normal range, return normal range + if !p.userNavigated || p.selectedEventIndex >= normalStartIdx { + indices := make([]int, 0, historySize-normalStartIdx) + for i := normalStartIdx; i < historySize; i++ { + indices = append(indices, i) + } + return indices + } + + // Selected event is outside normal range - include it as the first navigable event + // Show: selected event + last 9 chronological events + indices := make([]int, 0, maxNavigableEvents) + indices = append(indices, p.selectedEventIndex) // Add selected event first + + // Add the last 9 events (skip one to make room for the pinned event) + startIdx := historySize - (maxNavigableEvents - 1) + if startIdx < 0 { + startIdx = 0 + } + for i := startIdx; i < historySize; i++ { + // Skip the selected event if it's also in the last 9 (edge case) + if i != p.selectedEventIndex { + indices = append(indices, i) + } + } + + return indices +} + +// navigateEvents moves the selection up or down in the event history (within navigable events) func (p *Proxy) navigateEvents(direction int) { if len(p.eventHistory) == 0 { return } - // Calculate the navigable window (last 10 events) - navigableStartIdx := len(p.eventHistory) - maxNavigableEvents - if navigableStartIdx < 0 { - navigableStartIdx = 0 + // Get the navigable events (includes pinned selected event if applicable) + navigableIndices := p.getNavigableEvents() + if len(navigableIndices) == 0 { + return + } + + // Find current position in the navigable indices + currentPos := -1 + for i, idx := range navigableIndices { + if idx == p.selectedEventIndex { + currentPos = i + break + } } - newIndex := p.selectedEventIndex + direction + if currentPos == -1 { + // Selected event not in navigable list (shouldn't happen), default to first + currentPos = 0 + } + + // Calculate new position + newPos := currentPos + direction // Clamp to navigable range - if newIndex < navigableStartIdx { - newIndex = navigableStartIdx - } else if newIndex >= len(p.eventHistory) { - newIndex = len(p.eventHistory) - 1 + if newPos < 0 { + newPos = 0 + } else if newPos >= len(navigableIndices) { + newPos = len(navigableIndices) - 1 } - if newIndex != p.selectedEventIndex { - p.selectedEventIndex = newIndex + if newPos != currentPos { + p.selectedEventIndex = navigableIndices[newPos] p.userNavigated = true // Mark that user has manually navigated // Reset userNavigated if user navigates back to the latest event - if newIndex == len(p.eventHistory)-1 { + if p.selectedEventIndex == len(p.eventHistory)-1 { p.userNavigated = false } @@ -625,17 +689,20 @@ func (p *Proxy) redrawEventsWithSelection() { term.Restore(int(os.Stdin.Fd()), p.rawModeState) } - // Calculate the navigable window (last 10 events) - navigableStartIdx := len(p.eventHistory) - maxNavigableEvents - if navigableStartIdx < 0 { - navigableStartIdx = 0 + // Get the navigable events (includes pinned selected event if applicable) + navigableIndices := p.getNavigableEvents() + numNavigableEvents := len(navigableIndices) + + // Calculate the normal navigable start for determining if we need separator + normalNavigableStartIdx := len(p.eventHistory) - maxNavigableEvents + if normalNavigableStartIdx < 0 { + normalNavigableStartIdx = 0 } - numNavigableEvents := len(p.eventHistory) - navigableStartIdx // Move cursor up to start of navigable events and clear everything below linesToMoveUp := numNavigableEvents + 2 // navigable events + blank + status // If we have a separator, add 3 more lines (blank line + "Recent events" + blank line) - if navigableStartIdx > 0 { + if normalNavigableStartIdx > 0 { linesToMoveUp += 3 } fmt.Printf("\033[%dA", linesToMoveUp) @@ -644,17 +711,17 @@ func (p *Proxy) redrawEventsWithSelection() { // NOTE: We NEVER redraw the "Events" title - it was printed once and stays permanent // Add separator if there are historical events - if navigableStartIdx > 0 { + if normalNavigableStartIdx > 0 { color := ansi.Color(os.Stdout) fmt.Printf("\n%s\n\n", color.Faint("Latest events (↑↓ to navigate)")) // Extra newline after separator } - // Print only the navigable events with selection indicator - for i := navigableStartIdx; i < len(p.eventHistory); i++ { - if i == p.selectedEventIndex { - fmt.Printf("> %s\n", p.eventHistory[i].LogLine) // Selected event with > + // Print the navigable events (including pinned event if applicable) with selection indicator + for _, idx := range navigableIndices { + if idx == p.selectedEventIndex { + fmt.Printf("> %s\n", p.eventHistory[idx].LogLine) // Selected event with > } else { - fmt.Printf("%s\n", p.eventHistory[i].LogLine) // No indentation + fmt.Printf("%s\n", p.eventHistory[idx].LogLine) // No indentation } } @@ -666,14 +733,14 @@ func (p *Proxy) redrawEventsWithSelection() { color := ansi.Color(os.Stdout) selectedEvent := p.eventHistory[p.selectedEventIndex] if selectedEvent.Success { - statusMsg = fmt.Sprintf("> %s Selected event succeeded with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Selected event succeeded with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", color.Green("āœ“"), selectedEvent.Status) } else { if selectedEvent.Status == 0 { - statusMsg = fmt.Sprintf("> %s Selected event failed with error āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Selected event failed with error āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", color.Red("x").Bold()) } else { - statusMsg = fmt.Sprintf("> %s Selected event failed with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Selected event failed with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", color.Red("x").Bold(), selectedEvent.Status) } } From 16774fe8a9b0ddf0cf5cc844a88bd41a883377dc Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Fri, 3 Oct 2025 12:42:26 -0400 Subject: [PATCH 10/22] fix: Fix remaining issue --- MANUAL_TEST_PLAN.md | 386 -------------------------------------------- pkg/proxy/proxy.go | 130 ++++++++++----- 2 files changed, 91 insertions(+), 425 deletions(-) delete mode 100644 MANUAL_TEST_PLAN.md diff --git a/MANUAL_TEST_PLAN.md b/MANUAL_TEST_PLAN.md deleted file mode 100644 index c89b899..0000000 --- a/MANUAL_TEST_PLAN.md +++ /dev/null @@ -1,386 +0,0 @@ -# Manual Test Plan - Hookdeck CLI Listen Command - -This document outlines all the scenarios that should be manually tested for the `listen` command with the new UI improvements. - ---- - -## 1. Connection & Setup Scenarios - -### 1.1 Normal Connection -- **Test:** Start CLI with valid credentials and existing source -- **Expected:** - - Shows "Listening on" section with source details - - Shows animated green dot "ā— Connected. Waiting for events..." - - Websocket connects successfully - -### 1.2 First-Time Guest User (No API Key) -- **Test:** Run CLI without authentication -- **Expected:** - - Guest login flow triggers - - Shows: "šŸ’” Sign up to make your webhook URL permanent: [URL]" - - Creates temporary source - -### 1.3 WebSocket Connection Failure -- **Test:** Simulate websocket connection error (network issue, server down) -- **Expected:** - - Error message displayed - - Retry logic kicks in (up to 3 attempts) - - Graceful failure message if all attempts fail - -### 1.4 Invalid API Key -- **Test:** Use invalid/expired API key -- **Expected:** - - Authentication error message - - CLI exits with appropriate error - ---- - -## 2. Source Configuration Scenarios - -### 2.1 Single Source, Single Connection -- **Test:** Standard case with one source and one connection -- **Expected:** -``` -test -ā”œā”€ Request sent to → [webhook URL] -└─ Forwards to → [local URL] (connection_name) -``` - -### 2.2 Single Source, Multiple Connections -- **Test:** One source with 2-3 connections -- **Expected:** -``` -shopify -ā”œā”€ Request sent to → [webhook URL] -ā”œā”€ Forwards to → [local URL 1] (connection_1) -└─ Forwards to → [local URL 2] (connection_2) -``` - -### 2.3 Multiple Sources, Multiple Connections -- **Test:** 2-3 sources, each with 1-2 connections -- **Expected:** - - Each source shown with tree structure - - Blank line between sources - - All properly aligned - -### 2.4 Source Without Connections -- **Test:** Source exists but has no CLI connections configured -- **Expected:** - - Should show error or warning - - CLI should handle gracefully - -### 2.5 Non-Existent Source -- **Test:** Specify source name that doesn't exist -- **Expected:** - - Source gets created automatically - - Shows new source in "Listening on" section - ---- - -## 3. Event Handling Scenarios - -### 3.1 First Event Received -- **Test:** Send first webhook to source -- **Expected:** - - Animated green dot stops - - Event displays with proper formatting - - Status line shows: "> āœ“ Last event succeeded..." or "> x Last event failed..." - - Event auto-selected (has `>` indicator) - - Proper blank line before first event - -### 3.2 Successful Event (2xx Status) -- **Test:** Send webhook that returns 200-299 status -- **Expected:** - - Green āœ“ icon in status - - Event shows `[200]` or appropriate status code - - Status: "> āœ“ Last event succeeded with status 200" - -### 3.3 Failed Event (4xx/5xx Status) -- **Test:** Send webhook that returns 400/500 status -- **Expected:** - - Red x (bold) in status - - Event shows `[400]` or `[500]` - - Status: "> x Last event failed with status 500" - - Same red color for "x" and status code - -### 3.4 Multiple Events (Auto-selection) -- **Test:** Send 3-4 events without navigating -- **Expected:** - - Each new event auto-selected (gets `>`) - - Previous events show ` ` (no selection) - - Only ONE blank line between last event and status - - No duplicate events - - No duplicate status lines - -### 3.5 Connection Error (Local Server Down) -- **Test:** Stop local server, send webhook -- **Expected:** - - ERROR displayed with connection error message - - Status shows error with status 0 or similar - - Error tracked in event history - ---- - -## 4. Keyboard Navigation Scenarios - -### 4.1 Arrow Up Navigation -- **Test:** Press ↑ after receiving 3+ events -- **Expected:** - - `>` moves to previous event - - Status updates to reflect selected event - - `userNavigated` flag set - - No screen clearing (initial content preserved) - - No duplicate rows - -### 4.2 Arrow Down Navigation -- **Test:** Navigate up, then press ↓ -- **Expected:** - - `>` moves to next event - - Status updates correctly - - Can navigate back to latest event - -### 4.3 Navigate to First Event -- **Test:** Navigate all the way to first event (↑ multiple times) -- **Expected:** - - Stops at first event (index 0) - - `>` on first event - - Status reflects first event details - -### 4.4 Navigate to Last Event -- **Test:** Navigate down to last event -- **Expected:** - - Stops at last event - - `>` on last event - - Auto-selection resumes (userNavigated = false) - -### 4.5 New Event While Navigated -- **Test:** Navigate to old event, then new webhook arrives -- **Expected:** - - New event appears with ` ` (not selected) - - User stays on previously selected event - - Extra spacing handled correctly - - Selection doesn't jump to new event - -### 4.6 Navigate Back to Latest -- **Test:** Navigate away, then navigate back to latest event -- **Expected:** - - Auto-selection resumes for future events - - `userNavigated` flag reset - ---- - -## 5. Keyboard Actions - -### 5.1 Retry Event (r/R) -- **Test:** Select failed event, press 'r' -- **Expected:** - - API call to retry event - - Success/error message displayed - - Original status line restored - -### 5.2 Open in Dashboard (o/O) -- **Test:** Select any event, press 'o' -- **Expected:** - - Browser opens to event details page - - Correct URL with event ID - -### 5.3 Show Event Details (d/D) -- **Test:** Select any event, press 'd' -- **Expected:** - - Request details displayed (headers, body) - - Formatted nicely - - Status line restored after viewing - -### 5.4 Quit (q/Q) -- **Test:** Press 'q' -- **Expected:** - - Terminal restored to normal mode - - Clean exit - - No leftover artifacts - -### 5.5 Ctrl+C -- **Test:** Press Ctrl+C -- **Expected:** - - Same as quit - - Clean shutdown - - WebSocket connection closed properly - ---- - -## 6. Terminal Display Scenarios - -### 6.1 Waiting State Animation -- **Test:** Start CLI, don't send events for 5+ seconds -- **Expected:** - - Green dot alternates between ā— and ā—‹ every 500ms - - "Connected. Waiting for events..." message - - Animation smooth and visible - -### 6.2 Status Line Updates -- **Test:** Send multiple events, observe status line -- **Expected:** - - Status line always shows selected event info - - Proper clearing (no duplicate status lines) - - Keyboard shortcuts shown: [↑↓] Navigate • [r] Retry • [o] Open • [d] Details • [q] Quit - -### 6.3 Screen Clearing Behavior -- **Test:** Navigate between events -- **Expected:** - - Initial content (Listening on, hint, Events) preserved - - Only event area and status redrawn - - No flickering - - Clean transitions - -### 6.4 Long URLs -- **Test:** Use very long webhook URLs or local URLs -- **Expected:** - - URLs don't break formatting - - Tree structure maintained - - Still readable - -### 6.5 Many Events (10+) -- **Test:** Send 10-20 events rapidly -- **Expected:** - - All events displayed - - Scrolling works naturally - - Navigation works through all events - - Performance acceptable - ---- - -## 7. Edge Cases - -### 7.1 Empty Connection Name -- **Test:** Connection with empty or null name -- **Expected:** - - Handles gracefully (shows ID or default name) - -### 7.2 Special Characters in Names -- **Test:** Source/connection names with emoji, unicode, special chars -- **Expected:** - - Displays correctly - - No formatting issues - - Tree structure preserved - -### 7.3 Very Long Source Names -- **Test:** Source name with 50+ characters -- **Expected:** - - Displays without breaking layout - - Tree structure maintained - -### 7.4 Rapid Event Bursts -- **Test:** Send 5 events within 1 second -- **Expected:** - - All events captured - - Display updates correctly - - No race conditions - - No missing events - -### 7.5 Terminal Resize -- **Test:** Resize terminal window while running -- **Expected:** - - Layout adjusts reasonably - - No crashes - - Content still readable - -### 7.6 No Events for Extended Period -- **Test:** Let CLI run for 5+ minutes without events -- **Expected:** - - Animation continues smoothly - - No memory leaks - - Still responsive to input - ---- - -## 8. Multi-Source Scenarios - -### 8.1 Listen to All Sources (*) -- **Test:** `hookdeck listen 3000 *` -- **Expected:** - - All sources with CLI connections shown - - Multi-source message displayed - - Each source in tree format - -### 8.2 Switch Between Sources -- **Test:** Receive events from different sources -- **Expected:** - - Events grouped by source or shown chronologically - - Can identify which source each event came from - ---- - -## 9. Path Configuration - -### 9.1 Custom Path Flag -- **Test:** `hookdeck listen 3000 source --path /webhooks` -- **Expected:** - - Path shown in "Forwards to" URL - - Requests forwarded to correct path - -### 9.2 Path Update -- **Test:** Change path for existing destination -- **Expected:** - - Path update message shown - - New path reflected in display - ---- - -## 10. Console Mode - -### 10.1 Console Mode User -- **Test:** User in console mode (not dashboard mode) -- **Expected:** - - Console URL shown instead of dashboard URL - - šŸ’” hint shows console link - ---- - -## Testing Checklist - -Use this checklist to track testing progress: - -- [ ] 1.1 Normal Connection -- [ ] 1.2 Guest User -- [ ] 1.3 WebSocket Failure -- [ ] 1.4 Invalid API Key -- [ ] 2.1 Single Source/Connection -- [ ] 2.2 Multiple Connections -- [ ] 2.3 Multiple Sources -- [ ] 2.4 Source Without Connections -- [ ] 2.5 Non-Existent Source -- [ ] 3.1 First Event -- [ ] 3.2 Successful Event -- [ ] 3.3 Failed Event -- [ ] 3.4 Multiple Events -- [ ] 3.5 Connection Error -- [ ] 4.1-4.6 All Navigation Tests -- [ ] 5.1-5.5 All Keyboard Actions -- [ ] 6.1-6.5 All Display Tests -- [ ] 7.1-7.6 All Edge Cases -- [ ] 8.1-8.2 Multi-Source Tests -- [ ] 9.1-9.2 Path Tests -- [ ] 10.1 Console Mode - ---- - -## Known Issues to Watch For - -Based on previous fixes, pay special attention to: -1. Duplicate events appearing -2. Duplicate status lines -3. Missing newlines or extra newlines -4. Selection indicator (`>`) showing on multiple events -5. Screen clearing issues during navigation -6. First row duplication after navigation -7. Color consistency (red for errors) -8. Animation continuing after first event - ---- - -## Testing Environment - -- **Terminal:** iTerm2, Terminal.app, VSCode Terminal, etc. -- **OS:** macOS, Linux, Windows -- **Network:** Test with/without stable connection -- **Local Server:** Test with running/stopped local server - diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index c5f6f15..9605e4d 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -115,6 +115,26 @@ func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { return ctx } +// calculateEventLines calculates how many terminal lines an event log occupies +// accounting for line wrapping based on terminal width +func (p *Proxy) calculateEventLines(logLine string) int { + // Get terminal width + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + width = 80 // Default fallback + } + + // Add 2 for the potential "> " prefix or " " indentation + lineLength := len(logLine) + 2 + + // Calculate how many lines this will occupy + lines := (lineLength + width - 1) / width // Ceiling division + if lines < 1 { + lines = 1 + } + return lines +} + // safePrintf temporarily disables raw mode, prints the message, then re-enables raw mode func (p *Proxy) safePrintf(format string, args ...interface{}) { p.terminalMutex.Lock() @@ -261,7 +281,7 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { } } - // Add event to history + // Create event info eventInfo := EventInfo{ ID: p.latestEventID, Status: p.latestEventStatus, @@ -270,7 +290,22 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { Data: p.latestEventData, LogLine: eventLog, } - p.eventHistory = append(p.eventHistory, eventInfo) + + // Check if this exact event (same ID AND timestamp) already exists + // This prevents true duplicates but allows retries (same ID, different timestamp) as separate entries + isDuplicate := false + for i := len(p.eventHistory) - 1; i >= 0; i-- { + if p.eventHistory[i].ID == p.latestEventID && p.eventHistory[i].Time.Equal(p.latestEventTime) { + isDuplicate = true + break + } + } + + if !isDuplicate { + // Add to history (either new event or retry with different timestamp) + p.eventHistory = append(p.eventHistory, eventInfo) + } + // If it's a duplicate (same ID and timestamp), just skip adding it // Limit history to last 50 events - trim old ones if len(p.eventHistory) > maxHistorySize { @@ -310,8 +345,12 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { // Also redraw if user has navigated (to show pinned selection) if numNavigableEvents > 1 && !p.userNavigated { // Auto-selecting mode: redraw to move selection to latest - // Move cursor up to first navigable event and clear everything - linesToMoveUp := numNavigableEvents - 1 + 2 // previous navigable events + blank + status + // Calculate total terminal lines occupied by previous navigable events + totalEventLines := 0 + for i := navigableStartIdx; i < len(p.eventHistory)-1; i++ { + totalEventLines += p.calculateEventLines(p.eventHistory[i].LogLine) + } + linesToMoveUp := totalEventLines + 2 // previous event lines + blank + status fmt.Printf("\033[%dA", linesToMoveUp) fmt.Print("\033[J") @@ -328,8 +367,12 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { // Get the navigable events (includes pinned selected event if applicable) navigableIndices := p.getNavigableEvents() - // Move cursor up and redraw all navigable events - linesToMoveUp := len(navigableIndices) - 1 + 2 // previous navigable events + blank + status + // Calculate total terminal lines occupied by previous navigable events + totalEventLines := 0 + for i := 0; i < len(navigableIndices)-1; i++ { + totalEventLines += p.calculateEventLines(p.eventHistory[navigableIndices[i]].LogLine) + } + linesToMoveUp := totalEventLines + 2 // previous event lines + blank + status fmt.Printf("\033[%dA", linesToMoveUp) fmt.Print("\033[J") @@ -369,16 +412,35 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { // Generate status message var statusMsg string color := ansi.Color(os.Stdout) - if p.latestEventSuccess { - statusMsg = fmt.Sprintf("> %s Last event succeeded with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", - color.Green("āœ“"), p.latestEventStatus) + + // If user has navigated, show selected event status; otherwise show latest event status + if p.userNavigated && p.selectedEventIndex >= 0 && p.selectedEventIndex < len(p.eventHistory) { + selectedEvent := p.eventHistory[p.selectedEventIndex] + if selectedEvent.Success { + statusMsg = fmt.Sprintf("> %s Selected event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Green("āœ“"), selectedEvent.Status) + } else { + if selectedEvent.Status == 0 { + statusMsg = fmt.Sprintf("> %s Selected event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Red("x").Bold()) + } else { + statusMsg = fmt.Sprintf("> %s Selected event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Red("x").Bold(), selectedEvent.Status) + } + } } else { - if p.latestEventStatus == 0 { - statusMsg = fmt.Sprintf("> %s Last event failed with error āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", - color.Red("x").Bold()) + // Auto-selecting latest event + if p.latestEventSuccess { + statusMsg = fmt.Sprintf("> %s Last event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Green("āœ“"), p.latestEventStatus) } else { - statusMsg = fmt.Sprintf("> %s Last event failed with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", - color.Red("x").Bold(), p.latestEventStatus) + if p.latestEventStatus == 0 { + statusMsg = fmt.Sprintf("> %s Last event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Red("x").Bold()) + } else { + statusMsg = fmt.Sprintf("> %s Last event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + color.Red("x").Bold(), p.latestEventStatus) + } } } @@ -691,7 +753,6 @@ func (p *Proxy) redrawEventsWithSelection() { // Get the navigable events (includes pinned selected event if applicable) navigableIndices := p.getNavigableEvents() - numNavigableEvents := len(navigableIndices) // Calculate the normal navigable start for determining if we need separator normalNavigableStartIdx := len(p.eventHistory) - maxNavigableEvents @@ -699,9 +760,15 @@ func (p *Proxy) redrawEventsWithSelection() { normalNavigableStartIdx = 0 } + // Calculate total terminal lines occupied by navigable events + totalEventLines := 0 + for _, idx := range navigableIndices { + totalEventLines += p.calculateEventLines(p.eventHistory[idx].LogLine) + } + // Move cursor up to start of navigable events and clear everything below - linesToMoveUp := numNavigableEvents + 2 // navigable events + blank + status - // If we have a separator, add 3 more lines (blank line + "Recent events" + blank line) + linesToMoveUp := totalEventLines + 2 // event lines + blank + status + // If we have a separator, add 3 more lines (blank line + "Latest events" + blank line) if normalNavigableStartIdx > 0 { linesToMoveUp += 3 } @@ -733,14 +800,14 @@ func (p *Proxy) redrawEventsWithSelection() { color := ansi.Color(os.Stdout) selectedEvent := p.eventHistory[p.selectedEventIndex] if selectedEvent.Success { - statusMsg = fmt.Sprintf("> %s Selected event succeeded with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Selected event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", color.Green("āœ“"), selectedEvent.Status) } else { if selectedEvent.Status == 0 { - statusMsg = fmt.Sprintf("> %s Selected event failed with error āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Selected event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", color.Red("x").Bold()) } else { - statusMsg = fmt.Sprintf("> %s Selected event failed with status %d āŒØļø [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Selected event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", color.Red("x").Bold(), selectedEvent.Status) } } @@ -1187,23 +1254,6 @@ func (p *Proxy) retrySelectedEvent() { return } defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - color := ansi.Color(os.Stdout) - p.safePrintf("[%s] Failed to retry event %s (status: %d)\n", - color.Red("ERROR").Bold(), - eventID, - resp.StatusCode, - ) - return - } - - // Success feedback - color := ansi.Color(os.Stdout) - p.safePrintf("[%s] Event %s retry requested successfully\n", - color.Green("SUCCESS"), - eventID, - ) } func (p *Proxy) openSelectedEventURL() { @@ -1289,7 +1339,7 @@ func (p *Proxy) enterDetailsView() { // Header with navigation hints content.WriteString(ansi.Bold("Event Details")) content.WriteString("\n") - content.WriteString(ansi.Faint("āŒØļø Press 'q' to return to events • Use arrow keys/Page Up/Down to scroll")) + content.WriteString(ansi.Faint("| Press 'q' to return to events • Use arrow keys/Page Up/Down to scroll")) content.WriteString("\n") content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) content.WriteString("\n\n") @@ -1326,7 +1376,9 @@ func (p *Proxy) enterDetailsView() { // Request section content.WriteString(ansi.Bold("Request")) content.WriteString("\n\n") - content.WriteString(fmt.Sprintf("%s %s%s\n", color.Bold(webhookEvent.Body.Request.Method), p.cfg.URL.String(), webhookEvent.Body.Path)) + // Construct the full URL with query params the same way as in processAttempt + fullURL := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path + content.WriteString(fmt.Sprintf("%s %s\n", color.Bold(webhookEvent.Body.Request.Method), fullURL)) content.WriteString("\n") content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) content.WriteString("\n\n") From e37076d16fc2f5e72e7fca2d7bc18544d12d38b7 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Mon, 6 Oct 2025 12:27:43 -0400 Subject: [PATCH 11/22] fix: Fix typo --- pkg/listen/printer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index aad3615..7cced09 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -100,6 +100,6 @@ func printSourcesWithConnections(config *config.Config, sources []*hookdecksdk.S } // Create clickable link with OSC 8 hyperlink sequence // Format: \033]8;;URL\033\\DISPLAY_TEXT\033]8;;\033\\ - fmt.Printf("šŸ’” View dashboard to inspect, retry & boomark events: \033]8;;%s\033\\%s\033]8;;\033\\\n", url, displayURL) + fmt.Printf("šŸ’” View dashboard to inspect, retry & bookmark events: \033]8;;%s\033\\%s\033]8;;\033\\\n", url, displayURL) } } From c41aac2b918d5c639a3dd243457036ace4ac1f20 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Tue, 7 Oct 2025 14:06:32 -0400 Subject: [PATCH 12/22] chore: Terminology update --- pkg/listen/printer.go | 14 +++++--------- pkg/proxy/proxy.go | 22 +++++++++------------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index 7cced09..4664c3a 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -40,12 +40,8 @@ func printSourcesWithConnections(config *config.Config, sources []*hookdecksdk.S if sourceConns, exists := sourceConnections[source.Id]; exists { numConns := len(sourceConns) - // Print webhook URL with tree connector - if numConns > 0 { - fmt.Printf("ā”œā”€ Request sent to → %s\n", source.Url) - } else { - fmt.Printf("└─ Request sent to → %s\n", source.Url) - } + // Print webhook URL with vertical line only (no horizontal branch) + fmt.Printf("│ Requests to → %s\n", source.Url) // Print each connection for j, connection := range sourceConns { @@ -66,15 +62,15 @@ func printSourcesWithConnections(config *config.Config, sources []*hookdecksdk.S if j == numConns-1 { // Last connection - use └─ - fmt.Printf("└─ Forwards to → %s%s\n", fullPath, connNameDisplay) + fmt.Printf("└─ Forwards to → %s%s\n", fullPath, connNameDisplay) } else { // Not last connection - use ā”œā”€ - fmt.Printf("ā”œā”€ Forwards to → %s%s\n", fullPath, connNameDisplay) + fmt.Printf("ā”œā”€ Forwards to → %s%s\n", fullPath, connNameDisplay) } } } else { // No connections, just show webhook URL - fmt.Printf("└─ Request sent to → %s\n", source.Url) + fmt.Printf(" Request sents to → %s\n", source.Url) } // Add spacing between sources (but not after the last one) diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 9605e4d..1755d37 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -417,28 +417,28 @@ func (p *Proxy) printEventAndUpdateStatus(eventLog string) { if p.userNavigated && p.selectedEventIndex >= 0 && p.selectedEventIndex < len(p.eventHistory) { selectedEvent := p.eventHistory[p.selectedEventIndex] if selectedEvent.Success { - statusMsg = fmt.Sprintf("> %s Selected event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Selected event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", color.Green("āœ“"), selectedEvent.Status) } else { if selectedEvent.Status == 0 { - statusMsg = fmt.Sprintf("> %s Selected event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Selected event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data & • [Ctrl+C] Quit", color.Red("x").Bold()) } else { - statusMsg = fmt.Sprintf("> %s Selected event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Selected event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", color.Red("x").Bold(), selectedEvent.Status) } } } else { // Auto-selecting latest event if p.latestEventSuccess { - statusMsg = fmt.Sprintf("> %s Last event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Last event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", color.Green("āœ“"), p.latestEventStatus) } else { if p.latestEventStatus == 0 { - statusMsg = fmt.Sprintf("> %s Last event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Last event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit", color.Red("x").Bold()) } else { - statusMsg = fmt.Sprintf("> %s Last event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Last event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit", color.Red("x").Bold(), p.latestEventStatus) } } @@ -600,10 +600,6 @@ func (p *Proxy) processKeyboardInput(input []byte) { proc, _ := os.FindProcess(os.Getpid()) proc.Signal(os.Interrupt) return - case 0x71, 0x51: // 'q' or 'Q' - proc, _ := os.FindProcess(os.Getpid()) - proc.Signal(os.Interrupt) - return } } @@ -800,14 +796,14 @@ func (p *Proxy) redrawEventsWithSelection() { color := ansi.Color(os.Stdout) selectedEvent := p.eventHistory[p.selectedEventIndex] if selectedEvent.Success { - statusMsg = fmt.Sprintf("> %s Selected event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Selected event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit", color.Green("āœ“"), selectedEvent.Status) } else { if selectedEvent.Status == 0 { - statusMsg = fmt.Sprintf("> %s Selected event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Selected event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit", color.Red("x").Bold()) } else { - statusMsg = fmt.Sprintf("> %s Selected event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [q] Quit", + statusMsg = fmt.Sprintf("> %s Selected event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit", color.Red("x").Bold(), selectedEvent.Status) } } From abfc15df6374eef4185b9b97f6594892800a20f1 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Tue, 7 Oct 2025 15:02:15 -0400 Subject: [PATCH 13/22] chore: update readme --- README.md | 133 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 89 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index be603d5..2e05849 100644 --- a/README.md +++ b/README.md @@ -117,18 +117,53 @@ Hookdeck works by routing events received for a given `source` (i.e., Shopify, G Each `source` is assigned an Event URL, which you can use to receive events. When starting with a fresh account, the CLI will prompt you to create your first source. Each CLI process can listen to one source at a time. -#### Interactive Keyboard Shortcuts +#### Interactive Event Navigation -While the listen command is running, you can use the following keyboard shortcuts: +The listen command provides an interactive terminal experience that lets you view, navigate, and interact with incoming events in real-time. -- `↑` / `↓` - Navigate between events (select different events) -- `r` - Retry the selected event -- `o` - Open the selected event in the Hookdeck dashboard -- `d` - Show detailed request information for the selected event (headers, body, etc.) -- `q` - Quit the application -- `Ctrl+C` - Also quits the application +**Event Display:** +- Events are displayed as they arrive, showing timestamp, status code, HTTP method, URL, and dashboard link +- The most recent event is automatically selected (indicated by a `>` prefix) +- Up to 10 recent events remain navigable; older events become part of the scrollback history +- A status line at the bottom shows the selected event's status and available keyboard shortcuts -The selected event is indicated by a `>` character at the beginning of the line. All actions (retry, open, details) work on the currently selected event, not just the latest one. These shortcuts are displayed in the status line at the bottom of the terminal. +**Keyboard Shortcuts:** + +| Key | Action | Description | +|-----|--------|-------------| +| `↑` / `↓` | Navigate | Move selection between recent events | +| `r` | Retry | Resend the selected event to your local server | +| `o` | Open | Open the selected event in the Hookdeck dashboard | +| `d` | Details | View full event details (headers, body, etc.) in a scrollable view | +| `Ctrl+C` | Quit | Exit the listen session | + +**How It Works:** +1. When events arrive, they appear in the event list with their status (success āœ“ or failure āœ—) +2. Use `↑` / `↓` arrow keys to select any of the last 10 events +3. Press `r` to retry an event, `o` to inspect it in the dashboard, or `d` to see full request details +4. The details view opens in a pager (use arrow keys/Page Up/Down to scroll, `q` to return) +5. All actions operate on the currently selected event (shown with `>` prefix) + +**Example Session:** +``` +Listening on + +shopify +│ Requests to → https://events.hookdeck.com/e/src_ABC123 +└─ Forwards to → http://localhost:3000/webhooks/shopify + +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli + +Events + +2025-01-15 14:23:45 [200] POST http://localhost:3000/webhooks/shopify → https://... +2025-01-15 14:24:12 [200] POST http://localhost:3000/webhooks/shopify → https://... +> 2025-01-15 14:24:33 [500] POST http://localhost:3000/webhooks/shopify → https://... + +> āœ— Selected event failed with status 500 | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit +``` + +In this example, the third event (with status 500) is selected. You can press `r` to retry it, `o` to open it in the dashboard, or `d` to view the full request details including headers and body. Contrary to ngrok, **Hookdeck does not allow to append a path to your event URL**. Instead, the routing is done within Hookdeck configuration. This means you will also be prompted to specify your `destination` path, and you can have as many as you want per `source`. @@ -141,17 +176,18 @@ The second param, `source-alias` is used to select a specific source to listen o ```sh $ hookdeck listen 3000 shopify -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +Listening on -Shopify Source -šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +shopify +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +ā”œā”€ Forwards to → http://localhost:3000/webhooks/shopify/inventory (cli-shopify) +└─ Forwards to → http://localhost:3000/webhooks/shopify/orders (cli-shopify) -Connections -Inventory Service forwarding to /webhooks/shopify/inventory -Orders Service forwarding to /webhooks/shopify/orders +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli +Events -⣾ Getting ready... +ā— Connected. Waiting for events... ``` @@ -162,19 +198,25 @@ Orders Service forwarding to /webhooks/shopify/orders ```sh $ hookdeck listen 3000 '*' -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +Listening on + +stripe +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn01 +└─ Forwards to → http://localhost:3000/webhooks/stripe (cli-stripe) + +shopify +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn02 +└─ Forwards to → http://localhost:3000/webhooks/shopify (cli-shopify) + +twilio +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn03 +└─ Forwards to → http://localhost:3000/webhooks/twilio (cli-twilio) -Sources -šŸ”Œ stripe URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn01 -šŸ”Œ shopify URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn02 -šŸ”Œ twilio URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn03 +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli -Connections -stripe -> cli-stripe forwarding to /webhooks/stripe -shopify -> cli-shopify forwarding to /webhooks/shopify -twilio -> cli-twilio forwarding to /webhooks/twilio +Events -⣾ Getting ready... +ā— Connected. Waiting for events... ``` @@ -185,16 +227,17 @@ The 3rd param, `connection-query` can be used to filter the list of connections ```sh $ hookdeck listen 3000 shopify orders -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +Listening on -Shopify Source -šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +shopify +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +└─ Forwards to → http://localhost:3000/webhooks/shopify/orders (orders) -Connections -Orders Service forwarding to /webhooks/shopify/orders +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli +Events -⣾ Getting ready... +ā— Connected. Waiting for events... ``` @@ -205,16 +248,17 @@ The `--path` flag sets the path to which events are forwarded. ```sh $ hookdeck listen 3000 shopify orders --path /events/shopify/orders -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +Listening on -Shopify Source -šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +shopify +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +└─ Forwards to → http://localhost:3000/events/shopify/orders (orders) -Connections -Orders Service forwarding to /events/shopify/orders +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli +Events -⣾ Getting ready... +ā— Connected. Waiting for events... ``` @@ -269,16 +313,17 @@ Done! The Hookdeck CLI is configured in project MyProject $ hookdeck listen 3000 shopify orders -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +Listening on -Shopify Source -šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +shopify +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +└─ Forwards to → http://localhost:3000/webhooks/shopify/orders (orders) -Connections -Inventory Service forwarding to /webhooks/shopify/inventory +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli +Events -⣾ Getting ready... +ā— Connected. Waiting for events... ``` From 6d6f1e214678fe38f2c219c84bef6a9bd24efcb8 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Wed, 8 Oct 2025 11:47:34 -0400 Subject: [PATCH 14/22] chore: Update readme --- README.md | 133 ++++++++++++++++++------------------------------------ 1 file changed, 44 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 2e05849..be603d5 100644 --- a/README.md +++ b/README.md @@ -117,53 +117,18 @@ Hookdeck works by routing events received for a given `source` (i.e., Shopify, G Each `source` is assigned an Event URL, which you can use to receive events. When starting with a fresh account, the CLI will prompt you to create your first source. Each CLI process can listen to one source at a time. -#### Interactive Event Navigation +#### Interactive Keyboard Shortcuts -The listen command provides an interactive terminal experience that lets you view, navigate, and interact with incoming events in real-time. +While the listen command is running, you can use the following keyboard shortcuts: -**Event Display:** -- Events are displayed as they arrive, showing timestamp, status code, HTTP method, URL, and dashboard link -- The most recent event is automatically selected (indicated by a `>` prefix) -- Up to 10 recent events remain navigable; older events become part of the scrollback history -- A status line at the bottom shows the selected event's status and available keyboard shortcuts +- `↑` / `↓` - Navigate between events (select different events) +- `r` - Retry the selected event +- `o` - Open the selected event in the Hookdeck dashboard +- `d` - Show detailed request information for the selected event (headers, body, etc.) +- `q` - Quit the application +- `Ctrl+C` - Also quits the application -**Keyboard Shortcuts:** - -| Key | Action | Description | -|-----|--------|-------------| -| `↑` / `↓` | Navigate | Move selection between recent events | -| `r` | Retry | Resend the selected event to your local server | -| `o` | Open | Open the selected event in the Hookdeck dashboard | -| `d` | Details | View full event details (headers, body, etc.) in a scrollable view | -| `Ctrl+C` | Quit | Exit the listen session | - -**How It Works:** -1. When events arrive, they appear in the event list with their status (success āœ“ or failure āœ—) -2. Use `↑` / `↓` arrow keys to select any of the last 10 events -3. Press `r` to retry an event, `o` to inspect it in the dashboard, or `d` to see full request details -4. The details view opens in a pager (use arrow keys/Page Up/Down to scroll, `q` to return) -5. All actions operate on the currently selected event (shown with `>` prefix) - -**Example Session:** -``` -Listening on - -shopify -│ Requests to → https://events.hookdeck.com/e/src_ABC123 -└─ Forwards to → http://localhost:3000/webhooks/shopify - -šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli - -Events - -2025-01-15 14:23:45 [200] POST http://localhost:3000/webhooks/shopify → https://... -2025-01-15 14:24:12 [200] POST http://localhost:3000/webhooks/shopify → https://... -> 2025-01-15 14:24:33 [500] POST http://localhost:3000/webhooks/shopify → https://... - -> āœ— Selected event failed with status 500 | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit -``` - -In this example, the third event (with status 500) is selected. You can press `r` to retry it, `o` to open it in the dashboard, or `d` to view the full request details including headers and body. +The selected event is indicated by a `>` character at the beginning of the line. All actions (retry, open, details) work on the currently selected event, not just the latest one. These shortcuts are displayed in the status line at the bottom of the terminal. Contrary to ngrok, **Hookdeck does not allow to append a path to your event URL**. Instead, the routing is done within Hookdeck configuration. This means you will also be prompted to specify your `destination` path, and you can have as many as you want per `source`. @@ -176,18 +141,17 @@ The second param, `source-alias` is used to select a specific source to listen o ```sh $ hookdeck listen 3000 shopify -Listening on +šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events -shopify -│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH -ā”œā”€ Forwards to → http://localhost:3000/webhooks/shopify/inventory (cli-shopify) -└─ Forwards to → http://localhost:3000/webhooks/shopify/orders (cli-shopify) +Shopify Source +šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH -šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli +Connections +Inventory Service forwarding to /webhooks/shopify/inventory +Orders Service forwarding to /webhooks/shopify/orders -Events -ā— Connected. Waiting for events... +⣾ Getting ready... ``` @@ -198,25 +162,19 @@ Events ```sh $ hookdeck listen 3000 '*' -Listening on - -stripe -│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn01 -└─ Forwards to → http://localhost:3000/webhooks/stripe (cli-stripe) - -shopify -│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn02 -└─ Forwards to → http://localhost:3000/webhooks/shopify (cli-shopify) - -twilio -│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn03 -└─ Forwards to → http://localhost:3000/webhooks/twilio (cli-twilio) +šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events -šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli +Sources +šŸ”Œ stripe URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn01 +šŸ”Œ shopify URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn02 +šŸ”Œ twilio URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn03 -Events +Connections +stripe -> cli-stripe forwarding to /webhooks/stripe +shopify -> cli-shopify forwarding to /webhooks/shopify +twilio -> cli-twilio forwarding to /webhooks/twilio -ā— Connected. Waiting for events... +⣾ Getting ready... ``` @@ -227,17 +185,16 @@ The 3rd param, `connection-query` can be used to filter the list of connections ```sh $ hookdeck listen 3000 shopify orders -Listening on +šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events -shopify -│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH -└─ Forwards to → http://localhost:3000/webhooks/shopify/orders (orders) +Shopify Source +šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH -šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli +Connections +Orders Service forwarding to /webhooks/shopify/orders -Events -ā— Connected. Waiting for events... +⣾ Getting ready... ``` @@ -248,17 +205,16 @@ The `--path` flag sets the path to which events are forwarded. ```sh $ hookdeck listen 3000 shopify orders --path /events/shopify/orders -Listening on +šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events -shopify -│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH -└─ Forwards to → http://localhost:3000/events/shopify/orders (orders) +Shopify Source +šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH -šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli +Connections +Orders Service forwarding to /events/shopify/orders -Events -ā— Connected. Waiting for events... +⣾ Getting ready... ``` @@ -313,17 +269,16 @@ Done! The Hookdeck CLI is configured in project MyProject $ hookdeck listen 3000 shopify orders -Listening on +šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events -shopify -│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH -└─ Forwards to → http://localhost:3000/webhooks/shopify/orders (orders) +Shopify Source +šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH -šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli +Connections +Inventory Service forwarding to /webhooks/shopify/inventory -Events -ā— Connected. Waiting for events... +⣾ Getting ready... ``` From 5f737e77f9a5abe02d6a781ad0ec84b3634b0e69 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Wed, 8 Oct 2025 12:46:05 -0400 Subject: [PATCH 15/22] chore: Refactor --- pkg/proxy/event_actions.go | 281 +++++++++ pkg/proxy/event_history.go | 259 ++++++++ pkg/proxy/keyboard.go | 177 ++++++ pkg/proxy/proxy.go | 1144 +++--------------------------------- pkg/proxy/terminal_ui.go | 456 ++++++++++++++ 5 files changed, 1258 insertions(+), 1059 deletions(-) create mode 100644 pkg/proxy/event_actions.go create mode 100644 pkg/proxy/event_history.go create mode 100644 pkg/proxy/keyboard.go create mode 100644 pkg/proxy/terminal_ui.go diff --git a/pkg/proxy/event_actions.go b/pkg/proxy/event_actions.go new file mode 100644 index 0000000..a794e0c --- /dev/null +++ b/pkg/proxy/event_actions.go @@ -0,0 +1,281 @@ +package proxy + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "os/exec" + "runtime" + "sort" + "strconv" + "strings" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// EventActions handles actions on selected events (retry, open, view details) +type EventActions struct { + cfg *Config + eventHistory *EventHistory + ui *TerminalUI +} + +// NewEventActions creates a new EventActions instance +func NewEventActions(cfg *Config, eventHistory *EventHistory, ui *TerminalUI) *EventActions { + return &EventActions{ + cfg: cfg, + eventHistory: eventHistory, + ui: ui, + } +} + +// RetrySelectedEvent retries the currently selected event +func (ea *EventActions) RetrySelectedEvent() { + selectedEvent := ea.eventHistory.GetSelectedEvent() + if selectedEvent == nil { + color := ansi.Color(os.Stdout) + ea.ui.SafePrintf("[%s] No event selected to retry\n", + color.Yellow("WARN"), + ) + return + } + + eventID := selectedEvent.ID + if eventID == "" { + color := ansi.Color(os.Stdout) + ea.ui.SafePrintf("[%s] Selected event has no ID to retry\n", + color.Yellow("WARN"), + ) + return + } + + // Create HTTP client for retry request + parsedBaseURL, err := url.Parse(ea.cfg.APIBaseURL) + if err != nil { + color := ansi.Color(os.Stdout) + ea.ui.SafePrintf("[%s] Failed to parse API URL for retry: %v\n", + color.Red("ERROR").Bold(), + err, + ) + return + } + + client := &hookdeck.Client{ + BaseURL: parsedBaseURL, + APIKey: ea.cfg.Key, + ProjectID: ea.cfg.ProjectID, + } + + // Make retry request to Hookdeck API + retryURL := fmt.Sprintf("/events/%s/retry", eventID) + resp, err := client.Post(context.Background(), retryURL, []byte("{}"), nil) + if err != nil { + color := ansi.Color(os.Stdout) + ea.ui.SafePrintf("[%s] Failed to retry event %s: %v\n", + color.Red("ERROR").Bold(), + eventID, + err, + ) + return + } + defer resp.Body.Close() +} + +// OpenSelectedEventURL opens the currently selected event in the browser +func (ea *EventActions) OpenSelectedEventURL() { + selectedEvent := ea.eventHistory.GetSelectedEvent() + if selectedEvent == nil { + color := ansi.Color(os.Stdout) + ea.ui.SafePrintf("[%s] No event selected to open\n", + color.Yellow("WARN"), + ) + return + } + + eventID := selectedEvent.ID + if eventID == "" { + color := ansi.Color(os.Stdout) + ea.ui.SafePrintf("[%s] Selected event has no ID to open\n", + color.Yellow("WARN"), + ) + return + } + + // Build event URL based on project mode + var eventURL string + if ea.cfg.ProjectMode == "console" { + eventURL = ea.cfg.ConsoleBaseURL + "/?event_id=" + eventID + } else { + eventURL = ea.cfg.DashboardBaseURL + "/events/" + eventID + } + + // Open URL in browser + err := ea.openBrowser(eventURL) + if err != nil { + color := ansi.Color(os.Stdout) + ea.ui.SafePrintf("[%s] Failed to open browser: %v\n", + color.Red("ERROR").Bold(), + err, + ) + return + } +} + +// openBrowser opens a URL in the default browser +func (ea *EventActions) openBrowser(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start", url} + case "darwin": + cmd = "open" + args = []string{url} + default: // "linux", "freebsd", "openbsd", "netbsd" + cmd = "xdg-open" + args = []string{url} + } + + return exec.Command(cmd, args...).Start() +} + +// ShowEventDetails displays detailed event information using less pager +func (ea *EventActions) ShowEventDetails() (bool, error) { + selectedEvent := ea.eventHistory.GetSelectedEvent() + if selectedEvent == nil || selectedEvent.Data == nil { + return false, nil + } + + // Build the details content + webhookEvent := selectedEvent.Data + color := ansi.Color(os.Stdout) + var content strings.Builder + + // Header with navigation hints + content.WriteString(ansi.Bold("Event Details")) + content.WriteString("\n") + content.WriteString(ansi.Faint("| Press 'q' to return to events • Use arrow keys/Page Up/Down to scroll")) + content.WriteString("\n") + content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) + content.WriteString("\n\n") + + // Event metadata + timestampStr := selectedEvent.Time.Format(timeLayout) + statusIcon := color.Green("āœ“") + statusText := "succeeded" + statusDisplay := color.Bold(fmt.Sprintf("%d", selectedEvent.Status)) + if !selectedEvent.Success { + statusIcon = color.Red("x").Bold() + statusText = "failed" + if selectedEvent.Status == 0 { + statusDisplay = color.Bold("error") + } + } + + content.WriteString(fmt.Sprintf("%s Event %s with status %s at %s\n", statusIcon, statusText, statusDisplay, ansi.Faint(timestampStr))) + content.WriteString("\n") + + // Dashboard URL + dashboardURL := ea.cfg.DashboardBaseURL + if ea.cfg.ProjectID != "" { + dashboardURL += "/cli/events/" + selectedEvent.ID + } + if ea.cfg.ProjectMode == "console" { + dashboardURL = ea.cfg.ConsoleBaseURL + } + content.WriteString(fmt.Sprintf("%s %s\n", ansi.Faint("šŸ”—"), ansi.Faint(dashboardURL))) + content.WriteString("\n") + content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) + content.WriteString("\n\n") + + // Request section + content.WriteString(ansi.Bold("Request")) + content.WriteString("\n\n") + // Construct the full URL with query params + fullURL := ea.cfg.URL.Scheme + "://" + ea.cfg.URL.Host + ea.cfg.URL.Path + webhookEvent.Body.Path + content.WriteString(fmt.Sprintf("%s %s\n", color.Bold(webhookEvent.Body.Request.Method), fullURL)) + content.WriteString("\n") + content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) + content.WriteString("\n\n") + + // Headers section + if len(webhookEvent.Body.Request.Headers) > 0 { + content.WriteString(ansi.Bold("Headers")) + content.WriteString("\n\n") + + var headers map[string]json.RawMessage + if err := json.Unmarshal(webhookEvent.Body.Request.Headers, &headers); err == nil { + keys := make([]string, 0, len(headers)) + for key := range headers { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + unquoted, _ := strconv.Unquote(string(headers[key])) + content.WriteString(fmt.Sprintf("%s: %s\n", ansi.Faint(strings.ToLower(key)), unquoted)) + } + } + content.WriteString("\n") + content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) + content.WriteString("\n\n") + } + + // Body section + if len(webhookEvent.Body.Request.DataString) > 0 { + content.WriteString(ansi.Bold("Body")) + content.WriteString("\n\n") + + var bodyData interface{} + if err := json.Unmarshal([]byte(webhookEvent.Body.Request.DataString), &bodyData); err == nil { + prettyJSON, err := json.MarshalIndent(bodyData, "", " ") + if err == nil { + content.WriteString(string(prettyJSON)) + content.WriteString("\n") + } + } else { + content.WriteString(webhookEvent.Body.Request.DataString) + content.WriteString("\n") + } + } + + // Footer + content.WriteString("\n") + content.WriteString(fmt.Sprintf("%s Use arrow keys/Page Up/Down to scroll • Press 'q' to return to events\n", ansi.Faint("āŒØļø"))) + + // Use less with standard options + cmd := exec.Command("sh", "-c", "less -R") + + // Create stdin pipe to send content + stdinPipe, err := cmd.StdinPipe() + if err != nil { + // Fallback: print directly + fmt.Print(content.String()) + return false, nil + } + + // Connect to terminal + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Start less + if err := cmd.Start(); err != nil { + // Fallback: print directly + fmt.Print(content.String()) + return false, nil + } + + // Write content to less + stdinPipe.Write([]byte(content.String())) + stdinPipe.Close() + + // Wait for less to exit + cmd.Wait() + + return true, nil +} diff --git a/pkg/proxy/event_history.go b/pkg/proxy/event_history.go new file mode 100644 index 0000000..9bfa4fa --- /dev/null +++ b/pkg/proxy/event_history.go @@ -0,0 +1,259 @@ +package proxy + +import ( + "sync" + "time" + + "github.com/hookdeck/hookdeck-cli/pkg/websocket" +) + +// EventInfo represents a single event for navigation +type EventInfo struct { + ID string + Status int + Success bool + Time time.Time + Data *websocket.Attempt + LogLine string +} + +// EventHistory manages the history of events and navigation state +type EventHistory struct { + mu sync.RWMutex + events []EventInfo + selectedIndex int + userNavigated bool // Track if user has manually navigated away from latest event + eventsTitleDisplayed bool // Track if "Events" title has been displayed +} + +// NewEventHistory creates a new EventHistory instance +func NewEventHistory() *EventHistory { + return &EventHistory{ + events: make([]EventInfo, 0), + selectedIndex: -1, // Initialize to invalid index + } +} + +// AddEvent adds a new event to the history +// Returns true if the event was added, false if it was a duplicate +func (eh *EventHistory) AddEvent(eventInfo EventInfo) bool { + eh.mu.Lock() + defer eh.mu.Unlock() + + // Check if this exact event (same ID AND timestamp) already exists + // This prevents true duplicates but allows retries (same ID, different timestamp) as separate entries + for i := len(eh.events) - 1; i >= 0; i-- { + if eh.events[i].ID == eventInfo.ID && eh.events[i].Time.Equal(eventInfo.Time) { + return false // Duplicate + } + } + + // Add to history (either new event or retry with different timestamp) + eh.events = append(eh.events, eventInfo) + + // Limit history to last 50 events - trim old ones + if len(eh.events) > maxHistorySize { + // Remove oldest event + removedCount := len(eh.events) - maxHistorySize + eh.events = eh.events[removedCount:] + + // Adjust selected index if it was pointing to a removed event + if eh.selectedIndex < removedCount { + eh.selectedIndex = 0 + eh.userNavigated = false // Reset navigation since selected event was removed + } else { + eh.selectedIndex -= removedCount + } + } + + // Auto-select the latest event unless user has navigated away + if !eh.userNavigated { + eh.selectedIndex = len(eh.events) - 1 + } + + return true +} + +// GetEvents returns a copy of all events in the history +func (eh *EventHistory) GetEvents() []EventInfo { + eh.mu.RLock() + defer eh.mu.RUnlock() + + // Return a copy to prevent external modifications + eventsCopy := make([]EventInfo, len(eh.events)) + copy(eventsCopy, eh.events) + return eventsCopy +} + +// GetSelectedIndex returns the currently selected event index +func (eh *EventHistory) GetSelectedIndex() int { + eh.mu.RLock() + defer eh.mu.RUnlock() + return eh.selectedIndex +} + +// GetSelectedEvent returns a copy of the currently selected event, or nil if no event is selected +// Returns a copy to avoid issues with slice reallocation and concurrent modifications +func (eh *EventHistory) GetSelectedEvent() *EventInfo { + eh.mu.RLock() + defer eh.mu.RUnlock() + + if eh.selectedIndex < 0 || eh.selectedIndex >= len(eh.events) { + return nil + } + // Return a copy of the event to avoid pointer issues when slice is modified + eventCopy := eh.events[eh.selectedIndex] + return &eventCopy +} + +// IsUserNavigated returns true if the user has manually navigated away from the latest event +func (eh *EventHistory) IsUserNavigated() bool { + eh.mu.RLock() + defer eh.mu.RUnlock() + return eh.userNavigated +} + +// IsEventsTitleDisplayed returns true if the "Events" title has been displayed +func (eh *EventHistory) IsEventsTitleDisplayed() bool { + eh.mu.RLock() + defer eh.mu.RUnlock() + return eh.eventsTitleDisplayed +} + +// SetEventsTitleDisplayed sets whether the "Events" title has been displayed +func (eh *EventHistory) SetEventsTitleDisplayed(displayed bool) { + eh.mu.Lock() + defer eh.mu.Unlock() + eh.eventsTitleDisplayed = displayed +} + +// Count returns the number of events in the history +func (eh *EventHistory) Count() int { + eh.mu.RLock() + defer eh.mu.RUnlock() + return len(eh.events) +} + +// GetNavigableEvents returns the indices of events that should be shown in the "Latest events" section +// This includes the last (maxNavigableEvents-1) chronological events, plus the selected event if it's outside this range +func (eh *EventHistory) GetNavigableEvents() []int { + eh.mu.RLock() + defer eh.mu.RUnlock() + + historySize := len(eh.events) + + // Calculate the normal navigable range (last 10 events) + normalStartIdx := historySize - maxNavigableEvents + if normalStartIdx < 0 { + normalStartIdx = 0 + } + + // If user hasn't navigated or selected event is within normal range, return normal range + if !eh.userNavigated || eh.selectedIndex >= normalStartIdx { + indices := make([]int, 0, historySize-normalStartIdx) + for i := normalStartIdx; i < historySize; i++ { + indices = append(indices, i) + } + return indices + } + + // Selected event is outside normal range - include it as the first navigable event + // Show: selected event + last 9 chronological events + indices := make([]int, 0, maxNavigableEvents) + indices = append(indices, eh.selectedIndex) // Add selected event first + + // Add the last 9 events (skip one to make room for the pinned event) + startIdx := historySize - (maxNavigableEvents - 1) + if startIdx < 0 { + startIdx = 0 + } + for i := startIdx; i < historySize; i++ { + // Skip the selected event if it's also in the last 9 (edge case) + if i != eh.selectedIndex { + indices = append(indices, i) + } + } + + return indices +} + +// Navigate moves the selection up or down in the event history (within navigable events) +// direction: -1 for up, +1 for down +// Returns true if the selection changed, false otherwise +func (eh *EventHistory) Navigate(direction int) bool { + eh.mu.Lock() + defer eh.mu.Unlock() + + if len(eh.events) == 0 { + return false + } + + // Calculate navigable indices (inline to avoid double-locking) + historySize := len(eh.events) + normalStartIdx := historySize - maxNavigableEvents + if normalStartIdx < 0 { + normalStartIdx = 0 + } + + var navigableIndices []int + if !eh.userNavigated || eh.selectedIndex >= normalStartIdx { + navigableIndices = make([]int, 0, historySize-normalStartIdx) + for i := normalStartIdx; i < historySize; i++ { + navigableIndices = append(navigableIndices, i) + } + } else { + navigableIndices = make([]int, 0, maxNavigableEvents) + navigableIndices = append(navigableIndices, eh.selectedIndex) + startIdx := historySize - (maxNavigableEvents - 1) + if startIdx < 0 { + startIdx = 0 + } + for i := startIdx; i < historySize; i++ { + if i != eh.selectedIndex { + navigableIndices = append(navigableIndices, i) + } + } + } + + if len(navigableIndices) == 0 { + return false + } + + // Find current position in the navigable indices + currentPos := -1 + for i, idx := range navigableIndices { + if idx == eh.selectedIndex { + currentPos = i + break + } + } + + if currentPos == -1 { + // Selected event not in navigable list (shouldn't happen), default to first + currentPos = 0 + } + + // Calculate new position + newPos := currentPos + direction + + // Clamp to navigable range + if newPos < 0 { + newPos = 0 + } else if newPos >= len(navigableIndices) { + newPos = len(navigableIndices) - 1 + } + + if newPos != currentPos { + eh.selectedIndex = navigableIndices[newPos] + eh.userNavigated = true // Mark that user has manually navigated + + // Reset userNavigated if user navigates back to the latest event + if eh.selectedIndex == len(eh.events)-1 { + eh.userNavigated = false + } + + return true + } + + return false +} diff --git a/pkg/proxy/keyboard.go b/pkg/proxy/keyboard.go new file mode 100644 index 0000000..73b0355 --- /dev/null +++ b/pkg/proxy/keyboard.go @@ -0,0 +1,177 @@ +package proxy + +import ( + "context" + "os" + + log "github.com/sirupsen/logrus" + "golang.org/x/term" +) + +// KeyboardHandler handles keyboard input and raw mode management +type KeyboardHandler struct { + ui *TerminalUI + hasReceivedEvent *bool + isConnected *bool + showingDetails *bool + // Callbacks for actions + onNavigate func(direction int) + onRetry func() + onOpen func() + onToggleDetails func() + onQuit func() +} + +// NewKeyboardHandler creates a new KeyboardHandler instance +func NewKeyboardHandler(ui *TerminalUI, hasReceivedEvent *bool, isConnected *bool, showingDetails *bool) *KeyboardHandler { + return &KeyboardHandler{ + ui: ui, + hasReceivedEvent: hasReceivedEvent, + isConnected: isConnected, + showingDetails: showingDetails, + } +} + +// SetCallbacks sets the action callbacks +func (kh *KeyboardHandler) SetCallbacks( + onNavigate func(direction int), + onRetry func(), + onOpen func(), + onToggleDetails func(), + onQuit func(), +) { + kh.onNavigate = onNavigate + kh.onRetry = onRetry + kh.onOpen = onOpen + kh.onToggleDetails = onToggleDetails + kh.onQuit = onQuit +} + +// Start begins listening for keyboard input +func (kh *KeyboardHandler) Start(ctx context.Context) { + // Check if we're in a terminal + if !term.IsTerminal(int(os.Stdin.Fd())) { + return + } + + go func() { + // Enter raw mode once and keep it + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return + } + + // Store the raw mode state for use in UI rendering + kh.ui.SetRawModeState(oldState) + + // Ensure we restore terminal state when this goroutine exits + defer term.Restore(int(os.Stdin.Fd()), oldState) + + // Create a buffered channel for reading stdin + inputCh := make(chan []byte, 1) + + // Start a separate goroutine to read from stdin + go func() { + defer close(inputCh) + buf := make([]byte, 3) // Buffer for escape sequences + for { + select { + case <-ctx.Done(): + return + default: + n, err := os.Stdin.Read(buf) + if err != nil { + // Log the error but don't crash the application + log.WithField("prefix", "proxy.KeyboardHandler.Start").Debugf("Error reading stdin: %v", err) + return + } + if n == 0 { + continue + } + select { + case inputCh <- buf[:n]: + case <-ctx.Done(): + return + } + } + } + }() + + // Main loop to process keyboard input + for { + select { + case <-ctx.Done(): + return + case input, ok := <-inputCh: + if !ok { + return + } + + // Process the input + kh.processInput(input) + } + } + }() +} + +// processInput handles keyboard input including arrow keys +func (kh *KeyboardHandler) processInput(input []byte) { + if len(input) == 0 { + return + } + + // Handle single character keys + if len(input) == 1 { + switch input[0] { + case 0x03: // Ctrl+C + if kh.onQuit != nil { + kh.onQuit() + } + return + } + } + + // Disable all other shortcuts until first event is received or while not connected + if !*kh.hasReceivedEvent || !*kh.isConnected { + return + } + + // Handle escape sequences (arrow keys) + if len(input) == 3 && input[0] == 0x1B && input[1] == 0x5B { + // Disable navigation while in details view + if *kh.showingDetails { + return + } + + switch input[2] { + case 0x41: // Up arrow + if kh.onNavigate != nil { + kh.onNavigate(-1) + } + case 0x42: // Down arrow + if kh.onNavigate != nil { + kh.onNavigate(1) + } + } + return + } + + // Handle single character keys (after quit/ctrl+c check) + if len(input) == 1 { + switch input[0] { + case 0x72, 0x52: // 'r' or 'R' + if !*kh.showingDetails && kh.onRetry != nil { + kh.onRetry() + } + case 0x6F, 0x4F: // 'o' or 'O' + if kh.onOpen != nil { + kh.onOpen() + } + case 0x64, 0x44: // 'd' or 'D' + // Toggle alternate screen details view + if kh.onToggleDetails != nil { + kh.onToggleDetails() + } + } + } +} diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 1755d37..a2f2a81 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -11,18 +11,13 @@ import ( "net/http" "net/url" "os" - "os/exec" "os/signal" - "runtime" - "sort" "strconv" "strings" - "sync" "syscall" "time" log "github.com/sirupsen/logrus" - "golang.org/x/term" "github.com/hookdeck/hookdeck-cli/pkg/ansi" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" @@ -59,41 +54,21 @@ type Config struct { Insecure bool } -// EventInfo represents a single event for navigation -type EventInfo struct { - ID string - Status int - Success bool - Time time.Time - Data *websocket.Attempt - LogLine string -} - // A Proxy opens a websocket connection with Hookdeck, listens for incoming // webhook events, forwards them to the local endpoint and sends the response // back to Hookdeck. type Proxy struct { - cfg *Config - connections []*hookdecksdk.Connection - webSocketClient *websocket.Client - connectionTimer *time.Timer - latestEventID string - latestEventStatus int - latestEventSuccess bool - latestEventTime time.Time - latestEventData *websocket.Attempt - hasReceivedEvent bool - statusLineShown bool - terminalMutex sync.Mutex - rawModeState *term.State - // Event navigation - eventHistory []EventInfo - selectedEventIndex int - userNavigated bool // Track if user has manually navigated away from latest event - eventsTitleDisplayed bool // Track if "Events" title has been displayed - // Waiting animation - waitingAnimationFrame int - stopWaitingAnimation chan bool + cfg *Config + connections []*hookdecksdk.Connection + webSocketClient *websocket.Client + connectionTimer *time.Timer + hasReceivedEvent bool + stopWaitingAnimation chan bool + // UI and event management + ui *TerminalUI + eventHistory *EventHistory + keyboardHandler *KeyboardHandler + eventActions *EventActions // Details view showingDetails bool // Track if we're in alternate screen showing details // Connection state @@ -115,342 +90,20 @@ func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { return ctx } -// calculateEventLines calculates how many terminal lines an event log occupies -// accounting for line wrapping based on terminal width -func (p *Proxy) calculateEventLines(logLine string) int { - // Get terminal width - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 80 // Default fallback - } - - // Add 2 for the potential "> " prefix or " " indentation - lineLength := len(logLine) + 2 - - // Calculate how many lines this will occupy - lines := (lineLength + width - 1) / width // Ceiling division - if lines < 1 { - lines = 1 - } - return lines -} - -// safePrintf temporarily disables raw mode, prints the message, then re-enables raw mode -func (p *Proxy) safePrintf(format string, args ...interface{}) { - p.terminalMutex.Lock() - defer p.terminalMutex.Unlock() - - // Temporarily restore normal terminal mode for printing - if p.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), p.rawModeState) - } - - // Print the message - fmt.Printf(format, args...) - - // Re-enable raw mode - if p.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } -} - // printEventAndUpdateStatus prints the event log and updates the status line in one operation -func (p *Proxy) printEventAndUpdateStatus(eventLog string) { - p.terminalMutex.Lock() - defer p.terminalMutex.Unlock() - - // Check if this is the 11th event - need to add "Events" title before the first historical event - isEleventhEvent := len(p.eventHistory) == maxNavigableEvents && !p.eventsTitleDisplayed - - // If this is the 11th event, print the "Events" title now (before adding the event) - if isEleventhEvent { - // Temporarily restore normal terminal mode for printing - if p.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), p.rawModeState) - } - - // Move up to clear status line and blank line - fmt.Print("\033[2A\033[2K\r\033[1B\033[2K\r\033[1A") - - // Print "Events" title with newline above - color := ansi.Color(os.Stdout) - fmt.Printf("\n%s\n\n", color.Faint("Events")) - - // Print blank line and status that will be replaced - fmt.Println() - statusMsg := fmt.Sprintf("%s Adding...", color.Faint("ā—")) - fmt.Printf("%s\n", statusMsg) - - p.eventsTitleDisplayed = true - - // Re-enable raw mode - if p.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } - } - - // Check if any event will exit the navigable window when we add this new event - // We need to remove indentation from events becoming immutable - needToRedrawForExitingEvents := false - if len(p.eventHistory) >= maxNavigableEvents { - needToRedrawForExitingEvents = true - } - - // Check if we need to redraw due to selection changes - needToClearOldSelection := false - if p.userNavigated && len(p.eventHistory) > 0 { - // Calculate what the navigable range will be after adding the new event - futureHistorySize := len(p.eventHistory) + 1 - futureNavigableStartIdx := futureHistorySize - maxNavigableEvents - if futureNavigableStartIdx < 0 { - futureNavigableStartIdx = 0 - } - - // If current selection will be outside future navigable range, we need to redraw - // (The selected event will be pinned in the display, breaking chronological order) - if p.selectedEventIndex < futureNavigableStartIdx { - needToClearOldSelection = true - } - } - - // Redraw navigable window if events are exiting or selection is being cleared - // BUT skip if we just printed the Events title (11th event case) - if (needToRedrawForExitingEvents || needToClearOldSelection) && !isEleventhEvent { - // Temporarily restore normal terminal mode for printing - if p.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), p.rawModeState) - } - - // Calculate current navigable window - currentNavigableStartIdx := len(p.eventHistory) - maxNavigableEvents - if currentNavigableStartIdx < 0 { - currentNavigableStartIdx = 0 - } - currentNumNavigableEvents := len(p.eventHistory) - currentNavigableStartIdx - - // Calculate future navigable window to determine which event will become immutable - futureHistorySize := len(p.eventHistory) + 1 - futureNavigableStartIdx := futureHistorySize - maxNavigableEvents - if futureNavigableStartIdx < 0 { - futureNavigableStartIdx = 0 - } - - // Move cursor up and clear - // Account for: navigable events + separator (3 lines if present) + blank + status - linesToMoveUp := currentNumNavigableEvents + 2 // events + blank + status - // If we'll have a separator, add 3 more lines (blank line + "Recent events" + blank line) - if futureNavigableStartIdx > 0 { - linesToMoveUp += 3 - } - fmt.Printf("\033[%dA", linesToMoveUp) - fmt.Print("\033[J") - - // NOTE: We NEVER redraw the "Events" title - it was printed once and stays permanent - - // Redraw events - for i := currentNavigableStartIdx; i < len(p.eventHistory); i++ { - // Events that will become immutable (fall outside future navigable range) have no indentation - if i < futureNavigableStartIdx { - fmt.Printf("%s\n", p.eventHistory[i].LogLine) // No indentation - } else { - // Add "Latest events" separator before first navigable event - if i == futureNavigableStartIdx { - color := ansi.Color(os.Stdout) - fmt.Printf("\n%s\n\n", color.Faint("Latest events (↑↓ to navigate)")) // Extra newline after separator - } - // Only indent selected event with ">", others have no indentation - if i == p.selectedEventIndex { - fmt.Printf("> %s\n", p.eventHistory[i].LogLine) // Selected - } else { - fmt.Printf("%s\n", p.eventHistory[i].LogLine) // No indentation - } - } - } - - // Blank line - fmt.Println() - - // Status message (will be replaced soon) - color := ansi.Color(os.Stdout) - statusMsg := fmt.Sprintf("%s Updating...", color.Faint("ā—")) - fmt.Printf("%s\n", statusMsg) - - // Re-enable raw mode - if p.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } - } - - // Create event info +func (p *Proxy) printEventAndUpdateStatus(eventID string, status int, success bool, eventTime time.Time, eventData *websocket.Attempt, eventLog string) { + // Create event info with all data passed as parameters (no shared state) eventInfo := EventInfo{ - ID: p.latestEventID, - Status: p.latestEventStatus, - Success: p.latestEventSuccess, - Time: p.latestEventTime, - Data: p.latestEventData, + ID: eventID, + Status: status, + Success: success, + Time: eventTime, + Data: eventData, LogLine: eventLog, } - // Check if this exact event (same ID AND timestamp) already exists - // This prevents true duplicates but allows retries (same ID, different timestamp) as separate entries - isDuplicate := false - for i := len(p.eventHistory) - 1; i >= 0; i-- { - if p.eventHistory[i].ID == p.latestEventID && p.eventHistory[i].Time.Equal(p.latestEventTime) { - isDuplicate = true - break - } - } - - if !isDuplicate { - // Add to history (either new event or retry with different timestamp) - p.eventHistory = append(p.eventHistory, eventInfo) - } - // If it's a duplicate (same ID and timestamp), just skip adding it - - // Limit history to last 50 events - trim old ones - if len(p.eventHistory) > maxHistorySize { - // Remove oldest event - removedCount := len(p.eventHistory) - maxHistorySize - p.eventHistory = p.eventHistory[removedCount:] - - // Adjust selected index if it was pointing to a removed event - if p.selectedEventIndex < removedCount { - p.selectedEventIndex = 0 - p.userNavigated = false // Reset navigation since selected event was removed - } else { - p.selectedEventIndex -= removedCount - } - } - - // Auto-select the latest event unless user has navigated away - if !p.userNavigated { - p.selectedEventIndex = len(p.eventHistory) - 1 - } - // Note: If user has navigated, we DON'T change selectedEventIndex - // The display logic will handle showing it even if it's outside the normal navigable range - - // Temporarily restore normal terminal mode for printing - if p.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), p.rawModeState) - } - - // Calculate the navigable window (last 10 events) - navigableStartIdx := len(p.eventHistory) - maxNavigableEvents - if navigableStartIdx < 0 { - navigableStartIdx = 0 - } - numNavigableEvents := len(p.eventHistory) - navigableStartIdx - - // If we have multiple navigable events and auto-selecting, redraw navigable window - // Also redraw if user has navigated (to show pinned selection) - if numNavigableEvents > 1 && !p.userNavigated { - // Auto-selecting mode: redraw to move selection to latest - // Calculate total terminal lines occupied by previous navigable events - totalEventLines := 0 - for i := navigableStartIdx; i < len(p.eventHistory)-1; i++ { - totalEventLines += p.calculateEventLines(p.eventHistory[i].LogLine) - } - linesToMoveUp := totalEventLines + 2 // previous event lines + blank + status - fmt.Printf("\033[%dA", linesToMoveUp) - fmt.Print("\033[J") - - // Print navigable events with selection on the latest - for i := navigableStartIdx; i < len(p.eventHistory); i++ { - if i == p.selectedEventIndex { - fmt.Printf("> %s\n", p.eventHistory[i].LogLine) - } else { - fmt.Printf("%s\n", p.eventHistory[i].LogLine) // No indentation - } - } - } else if p.userNavigated && numNavigableEvents > 1 { - // User has navigated: redraw to show pinned selected event - // Get the navigable events (includes pinned selected event if applicable) - navigableIndices := p.getNavigableEvents() - - // Calculate total terminal lines occupied by previous navigable events - totalEventLines := 0 - for i := 0; i < len(navigableIndices)-1; i++ { - totalEventLines += p.calculateEventLines(p.eventHistory[navigableIndices[i]].LogLine) - } - linesToMoveUp := totalEventLines + 2 // previous event lines + blank + status - fmt.Printf("\033[%dA", linesToMoveUp) - fmt.Print("\033[J") - - // Print navigable events (including pinned event) with selection indicator - for _, idx := range navigableIndices { - if idx == p.selectedEventIndex { - fmt.Printf("> %s\n", p.eventHistory[idx].LogLine) - } else { - fmt.Printf("%s\n", p.eventHistory[idx].LogLine) - } - } - } else { - // First event - simple append - if p.statusLineShown { - if len(p.eventHistory) == 1 { - // First event - only clear the "waiting" status line - fmt.Print("\033[1A\033[2K\r") - } else { - // Clear status line and blank line - fmt.Print("\033[2A\033[2K\r\033[1B\033[2K\r\033[1A") - } - } - - // Print the new event - newEventIndex := len(p.eventHistory) - 1 - // Only indent if selected, otherwise no indentation - if p.selectedEventIndex == newEventIndex { - fmt.Printf("> %s\n", p.eventHistory[newEventIndex].LogLine) - } else { - fmt.Printf("%s\n", p.eventHistory[newEventIndex].LogLine) // No indentation - } - } - - // Blank line - fmt.Println() - - // Generate status message - var statusMsg string - color := ansi.Color(os.Stdout) - - // If user has navigated, show selected event status; otherwise show latest event status - if p.userNavigated && p.selectedEventIndex >= 0 && p.selectedEventIndex < len(p.eventHistory) { - selectedEvent := p.eventHistory[p.selectedEventIndex] - if selectedEvent.Success { - statusMsg = fmt.Sprintf("> %s Selected event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", - color.Green("āœ“"), selectedEvent.Status) - } else { - if selectedEvent.Status == 0 { - statusMsg = fmt.Sprintf("> %s Selected event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data & • [Ctrl+C] Quit", - color.Red("x").Bold()) - } else { - statusMsg = fmt.Sprintf("> %s Selected event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", - color.Red("x").Bold(), selectedEvent.Status) - } - } - } else { - // Auto-selecting latest event - if p.latestEventSuccess { - statusMsg = fmt.Sprintf("> %s Last event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", - color.Green("āœ“"), p.latestEventStatus) - } else { - if p.latestEventStatus == 0 { - statusMsg = fmt.Sprintf("> %s Last event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit", - color.Red("x").Bold()) - } else { - statusMsg = fmt.Sprintf("> %s Last event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit", - color.Red("x").Bold(), p.latestEventStatus) - } - } - } - - fmt.Printf("%s\n", statusMsg) - p.statusLineShown = true - - // Re-enable raw mode - if p.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } + // Delegate rendering to UI + p.ui.PrintEventAndUpdateStatus(eventInfo, p.hasReceivedEvent) } // startWaitingAnimation starts an animation for the waiting indicator @@ -468,352 +121,37 @@ func (p *Proxy) startWaitingAnimation(ctx context.Context) { case <-p.stopWaitingAnimation: return case <-ticker.C: - if !p.hasReceivedEvent && p.statusLineShown { - p.updateStatusLine() + if !p.hasReceivedEvent { + p.ui.UpdateStatusLine(p.hasReceivedEvent) } } } }() } -// updateStatusLine updates the bottom status line with the latest event information -func (p *Proxy) updateStatusLine() { - p.terminalMutex.Lock() - defer p.terminalMutex.Unlock() - - // Only update if we haven't received any events yet (just the waiting animation) - if p.hasReceivedEvent { - return - } - - // Temporarily restore normal terminal mode for printing - if p.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), p.rawModeState) - } - - // Animated green dot (alternates between ā— and ā—‹) - color := ansi.Color(os.Stdout) - var dot string - if p.waitingAnimationFrame%2 == 0 { - dot = fmt.Sprintf("%s", color.Green("ā—")) - } else { - dot = fmt.Sprintf("%s", color.Green("ā—‹")) - } - p.waitingAnimationFrame++ - statusMsg := fmt.Sprintf("%s Connected. Waiting for events...", dot) - - if p.statusLineShown { - // If we've shown a status before, move up one line and clear it - fmt.Printf("\033[1A\033[2K\r%s\n", statusMsg) - } else { - // First time showing status - fmt.Printf("%s\n", statusMsg) - p.statusLineShown = true - } - - // Re-enable raw mode - if p.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } +// quit handles Ctrl+C and q key to exit the application +func (p *Proxy) quit() { + proc, _ := os.FindProcess(os.Getpid()) + proc.Signal(os.Interrupt) } -func (p *Proxy) startKeyboardListener(ctx context.Context) { - // Check if we're in a terminal - if !term.IsTerminal(int(os.Stdin.Fd())) { - return - } - - go func() { - // Enter raw mode once and keep it - oldState, err := term.MakeRaw(int(os.Stdin.Fd())) - if err != nil { - return - } - - // Store the raw mode state for use in safePrintf - p.rawModeState = oldState - - // Ensure we restore terminal state when this goroutine exits - defer func() { - p.terminalMutex.Lock() - defer p.terminalMutex.Unlock() - term.Restore(int(os.Stdin.Fd()), oldState) - }() - - // Create a buffered channel for reading stdin - inputCh := make(chan []byte, 1) - - // Start a separate goroutine to read from stdin - go func() { - defer close(inputCh) - buf := make([]byte, 3) // Buffer for escape sequences - for { - select { - case <-ctx.Done(): - return - default: - n, err := os.Stdin.Read(buf) - if err != nil { - // Log the error but don't crash the application - log.WithField("prefix", "proxy.startKeyboardListener").Debugf("Error reading stdin: %v", err) - return - } - if n == 0 { - continue - } - select { - case inputCh <- buf[:n]: - case <-ctx.Done(): - return - } - } - } - }() - - // Main loop to process keyboard input - for { - select { - case <-ctx.Done(): - return - case input, ok := <-inputCh: - if !ok { - return - } - - // Process the input - p.processKeyboardInput(input) - } - } - }() -} - -// processKeyboardInput handles keyboard input including arrow keys -func (p *Proxy) processKeyboardInput(input []byte) { - if len(input) == 0 { - return - } - - // Handle single character keys - if len(input) == 1 { - switch input[0] { - case 0x03: // Ctrl+C - proc, _ := os.FindProcess(os.Getpid()) - proc.Signal(os.Interrupt) - return - } - } - - // Disable all other shortcuts until first event is received or while not connected - if !p.hasReceivedEvent || !p.isConnected { - return - } - - // Handle escape sequences (arrow keys) - if len(input) == 3 && input[0] == 0x1B && input[1] == 0x5B { - // Disable navigation while in details view - if p.showingDetails { - return - } - - switch input[2] { - case 0x41: // Up arrow - p.navigateEvents(-1) - case 0x42: // Down arrow - p.navigateEvents(1) - } - return - } - - // Handle single character keys (after quit/ctrl+c check) - if len(input) == 1 { - switch input[0] { - case 0x72, 0x52: // 'r' or 'R' - if !p.showingDetails { - p.retrySelectedEvent() - } - case 0x6F, 0x4F: // 'o' or 'O' - p.openSelectedEventURL() - case 0x64, 0x44: // 'd' or 'D' - // Toggle alternate screen details view - if p.showingDetails { - p.exitDetailsView() - } else { - p.enterDetailsView() - } - } - } -} - -// getNavigableEvents returns the indices of events that should be shown in the "Latest events" section -// This includes the last (maxNavigableEvents-1) chronological events, plus the selected event if it's outside this range -func (p *Proxy) getNavigableEvents() []int { - historySize := len(p.eventHistory) - - // Calculate the normal navigable range (last 10 events) - normalStartIdx := historySize - maxNavigableEvents - if normalStartIdx < 0 { - normalStartIdx = 0 - } - - // If user hasn't navigated or selected event is within normal range, return normal range - if !p.userNavigated || p.selectedEventIndex >= normalStartIdx { - indices := make([]int, 0, historySize-normalStartIdx) - for i := normalStartIdx; i < historySize; i++ { - indices = append(indices, i) - } - return indices - } - - // Selected event is outside normal range - include it as the first navigable event - // Show: selected event + last 9 chronological events - indices := make([]int, 0, maxNavigableEvents) - indices = append(indices, p.selectedEventIndex) // Add selected event first - - // Add the last 9 events (skip one to make room for the pinned event) - startIdx := historySize - (maxNavigableEvents - 1) - if startIdx < 0 { - startIdx = 0 - } - for i := startIdx; i < historySize; i++ { - // Skip the selected event if it's also in the last 9 (edge case) - if i != p.selectedEventIndex { - indices = append(indices, i) +// toggleDetailsView toggles between showing and hiding event details +func (p *Proxy) toggleDetailsView() { + if p.showingDetails { + p.showingDetails = false + } else { + shown, _ := p.eventActions.ShowEventDetails() + if shown { + p.showingDetails = true } } - - return indices } // navigateEvents moves the selection up or down in the event history (within navigable events) func (p *Proxy) navigateEvents(direction int) { - if len(p.eventHistory) == 0 { - return - } - - // Get the navigable events (includes pinned selected event if applicable) - navigableIndices := p.getNavigableEvents() - if len(navigableIndices) == 0 { - return - } - - // Find current position in the navigable indices - currentPos := -1 - for i, idx := range navigableIndices { - if idx == p.selectedEventIndex { - currentPos = i - break - } - } - - if currentPos == -1 { - // Selected event not in navigable list (shouldn't happen), default to first - currentPos = 0 - } - - // Calculate new position - newPos := currentPos + direction - - // Clamp to navigable range - if newPos < 0 { - newPos = 0 - } else if newPos >= len(navigableIndices) { - newPos = len(navigableIndices) - 1 - } - - if newPos != currentPos { - p.selectedEventIndex = navigableIndices[newPos] - p.userNavigated = true // Mark that user has manually navigated - - // Reset userNavigated if user navigates back to the latest event - if p.selectedEventIndex == len(p.eventHistory)-1 { - p.userNavigated = false - } - - p.redrawEventsWithSelection() - } -} - -// redrawEventsWithSelection updates the selection indicators without clearing the screen (only last 10 events) -func (p *Proxy) redrawEventsWithSelection() { - if len(p.eventHistory) == 0 { - return - } - - p.terminalMutex.Lock() - defer p.terminalMutex.Unlock() - - // Temporarily restore normal terminal mode for printing - if p.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), p.rawModeState) - } - - // Get the navigable events (includes pinned selected event if applicable) - navigableIndices := p.getNavigableEvents() - - // Calculate the normal navigable start for determining if we need separator - normalNavigableStartIdx := len(p.eventHistory) - maxNavigableEvents - if normalNavigableStartIdx < 0 { - normalNavigableStartIdx = 0 - } - - // Calculate total terminal lines occupied by navigable events - totalEventLines := 0 - for _, idx := range navigableIndices { - totalEventLines += p.calculateEventLines(p.eventHistory[idx].LogLine) - } - - // Move cursor up to start of navigable events and clear everything below - linesToMoveUp := totalEventLines + 2 // event lines + blank + status - // If we have a separator, add 3 more lines (blank line + "Latest events" + blank line) - if normalNavigableStartIdx > 0 { - linesToMoveUp += 3 - } - fmt.Printf("\033[%dA", linesToMoveUp) - fmt.Print("\033[J") - - // NOTE: We NEVER redraw the "Events" title - it was printed once and stays permanent - - // Add separator if there are historical events - if normalNavigableStartIdx > 0 { - color := ansi.Color(os.Stdout) - fmt.Printf("\n%s\n\n", color.Faint("Latest events (↑↓ to navigate)")) // Extra newline after separator - } - - // Print the navigable events (including pinned event if applicable) with selection indicator - for _, idx := range navigableIndices { - if idx == p.selectedEventIndex { - fmt.Printf("> %s\n", p.eventHistory[idx].LogLine) // Selected event with > - } else { - fmt.Printf("%s\n", p.eventHistory[idx].LogLine) // No indentation - } - } - - // Add a newline before the status line - fmt.Println() - - // Generate and print the status message for the selected event - var statusMsg string - color := ansi.Color(os.Stdout) - selectedEvent := p.eventHistory[p.selectedEventIndex] - if selectedEvent.Success { - statusMsg = fmt.Sprintf("> %s Selected event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit", - color.Green("āœ“"), selectedEvent.Status) - } else { - if selectedEvent.Status == 0 { - statusMsg = fmt.Sprintf("> %s Selected event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit", - color.Red("x").Bold()) - } else { - statusMsg = fmt.Sprintf("> %s Selected event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit", - color.Red("x").Bold(), selectedEvent.Status) - } - } - - fmt.Printf("%s\n", statusMsg) - p.statusLineShown = true - - // Re-enable raw mode - if p.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) + // Delegate to EventHistory and redraw if selection changed + if p.eventHistory.Navigate(direction) { + p.ui.RedrawEventsWithSelection(p.hasReceivedEvent) } } @@ -844,8 +182,8 @@ func (p *Proxy) Run(parentCtx context.Context) error { }).Debug("Ctrl+C received, cleaning up...") }) - // Start keyboard listener for Ctrl+R/Cmd+R shortcuts - p.startKeyboardListener(signalCtx) + // Start keyboard listener for keyboard shortcuts + p.keyboardHandler.Start(signalCtx) // Start waiting animation p.startWaitingAnimation(signalCtx) @@ -854,28 +192,16 @@ func (p *Proxy) Run(parentCtx context.Context) error { session, err := p.createSession(signalCtx) if err != nil { - // Stop spinner and restore terminal state before fatal error - p.terminalMutex.Lock() + // Stop spinner before fatal error (terminal will be restored by defer) ansi.StopSpinner(s, "", p.cfg.Log.Out) - if p.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), p.rawModeState) - } fmt.Print("\033[2K\r") - p.terminalMutex.Unlock() - p.cfg.Log.Fatalf("Error while authenticating with Hookdeck: %v", err) } if session.Id == "" { - // Stop spinner and restore terminal state before fatal error - p.terminalMutex.Lock() + // Stop spinner before fatal error (terminal will be restored by defer) ansi.StopSpinner(s, "", p.cfg.Log.Out) - if p.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), p.rawModeState) - } fmt.Print("\033[2K\r") - p.terminalMutex.Unlock() - p.cfg.Log.Fatalf("Error while starting a new session") } @@ -899,15 +225,6 @@ func (p *Proxy) Run(parentCtx context.Context) error { p.connectionTimer.Stop() p.connectionTimer.Reset(time.Duration(sleepDurationMS) * time.Millisecond) - // Clear the status line before showing reconnection spinner - p.terminalMutex.Lock() - if p.statusLineShown { - // Move up and clear the status line - fmt.Print("\033[1A\033[2K\r") - p.statusLineShown = false - } - p.terminalMutex.Unlock() - // Block with a spinner while waiting ansi.StopSpinner(s, "", p.cfg.Log.Out) // Use different message based on whether we've connected before @@ -945,25 +262,12 @@ func (p *Proxy) Run(parentCtx context.Context) error { p.isConnected = true nAttempts = 0 - // Stop the spinner and update status line - p.terminalMutex.Lock() - if p.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), p.rawModeState) - } + // Stop the spinner ansi.StopSpinner(s, "", p.cfg.Log.Out) - if p.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } - p.terminalMutex.Unlock() // Always update the status line to show current state - if hasConnectedOnce || p.hasReceivedEvent { - // If we've reconnected or have events, just update the status - p.updateStatusLine() - } else { - // First connection, show initial status - p.updateStatusLine() - } + p.ui.UpdateStatusLine(p.hasReceivedEvent) + hasConnectedOnce = true }() @@ -981,15 +285,9 @@ func (p *Proxy) Run(parentCtx context.Context) error { p.isConnected = false if !canConnect() { - // Stop the spinner and restore terminal state before fatal error - p.terminalMutex.Lock() + // Stop the spinner before fatal error (terminal will be restored by defer) ansi.StopSpinner(s, "", p.cfg.Log.Out) - if p.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), p.rawModeState) - } - // Clear the spinner line fmt.Print("\033[2K\r") - p.terminalMutex.Unlock() // Print error without timestamp (use fmt instead of log to avoid formatter) color := ansi.Color(os.Stdout) @@ -1057,17 +355,14 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { } webhookEvent := msg.Attempt - - // Store the latest event ID and data for retry/open/details functionality - p.latestEventID = webhookEvent.Body.EventID - p.latestEventData = webhookEvent + eventID := webhookEvent.Body.EventID p.cfg.Log.WithFields(log.Fields{ "prefix": "proxy.Proxy.processAttempt", }).Debugf("Processing webhook event") if p.cfg.PrintJSON { - p.safePrintf("%s\n", webhookEvent.Body.Request.DataString) + p.ui.SafePrintf("%s\n", webhookEvent.Body.Request.DataString) } else { url := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path tr := &http.Transport{ @@ -1086,13 +381,13 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { req, err := http.NewRequest(webhookEvent.Body.Request.Method, url, nil) if err != nil { - p.safePrintf("Error: %s\n", err) + p.ui.SafePrintf("Error: %s\n", err) return } x := make(map[string]json.RawMessage) err = json.Unmarshal(webhookEvent.Body.Request.Headers, &x) if err != nil { - p.safePrintf("Error: %s\n", err) + p.ui.SafePrintf("Error: %s\n", err) return } @@ -1117,10 +412,7 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { err, ) - // Track the failed event first - p.latestEventStatus = 0 // Use 0 for connection errors - p.latestEventSuccess = false - p.latestEventTime = time.Now() + // Mark as having received first event if !p.hasReceivedEvent { p.hasReceivedEvent = true // Stop the waiting animation @@ -1129,8 +421,8 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { } } - // Print the error and update status line in one operation - p.printEventAndUpdateStatus(errStr) + // Print the error and update status line with event-specific data + p.printEventAndUpdateStatus(eventID, 0, false, time.Now(), webhookEvent, errStr) p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ @@ -1147,7 +439,8 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { } func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *http.Response) { - localTime := time.Now().Format(timeLayout) + eventTime := time.Now() + localTime := eventTime.Format(timeLayout) color := ansi.Color(os.Stdout) var url = p.cfg.DashboardBaseURL + "/events/" + webhookEvent.Body.EventID if p.cfg.ProjectMode == "console" { @@ -1161,10 +454,13 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h color.Faint("→"), color.Faint(url), ) - // Track the event status first - p.latestEventStatus = resp.StatusCode - p.latestEventSuccess = resp.StatusCode >= 200 && resp.StatusCode < 300 - p.latestEventTime = time.Now() + + // Calculate event status + eventStatus := resp.StatusCode + eventSuccess := resp.StatusCode >= 200 && resp.StatusCode < 300 + eventID := webhookEvent.Body.EventID + + // Mark as having received first event if !p.hasReceivedEvent { p.hasReceivedEvent = true // Stop the waiting animation @@ -1173,8 +469,8 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h } } - // Print the event log and update status line in one operation - p.printEventAndUpdateStatus(outputStr) + // Print the event log and update status line with event-specific data + p.printEventAndUpdateStatus(eventID, eventStatus, eventSuccess, eventTime, webhookEvent, outputStr) buf, err := ioutil.ReadAll(resp.Body) if err != nil { @@ -1202,293 +498,6 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h } } -func (p *Proxy) retrySelectedEvent() { - if len(p.eventHistory) == 0 || p.selectedEventIndex < 0 || p.selectedEventIndex >= len(p.eventHistory) { - color := ansi.Color(os.Stdout) - p.safePrintf("[%s] No event selected to retry\n", - color.Yellow("WARN"), - ) - return - } - - eventID := p.eventHistory[p.selectedEventIndex].ID - if eventID == "" { - color := ansi.Color(os.Stdout) - p.safePrintf("[%s] Selected event has no ID to retry\n", - color.Yellow("WARN"), - ) - return - } - - // Create HTTP client for retry request - parsedBaseURL, err := url.Parse(p.cfg.APIBaseURL) - if err != nil { - color := ansi.Color(os.Stdout) - p.safePrintf("[%s] Failed to parse API URL for retry: %v\n", - color.Red("ERROR").Bold(), - err, - ) - return - } - - client := &hookdeck.Client{ - BaseURL: parsedBaseURL, - APIKey: p.cfg.Key, - ProjectID: p.cfg.ProjectID, - } - - // Make retry request to Hookdeck API - retryURL := fmt.Sprintf("/events/%s/retry", eventID) - resp, err := client.Post(context.Background(), retryURL, []byte("{}"), nil) - if err != nil { - color := ansi.Color(os.Stdout) - p.safePrintf("[%s] Failed to retry event %s: %v\n", - color.Red("ERROR").Bold(), - eventID, - err, - ) - return - } - defer resp.Body.Close() -} - -func (p *Proxy) openSelectedEventURL() { - if len(p.eventHistory) == 0 || p.selectedEventIndex < 0 || p.selectedEventIndex >= len(p.eventHistory) { - color := ansi.Color(os.Stdout) - p.safePrintf("[%s] No event selected to open\n", - color.Yellow("WARN"), - ) - return - } - - eventID := p.eventHistory[p.selectedEventIndex].ID - if eventID == "" { - color := ansi.Color(os.Stdout) - p.safePrintf("[%s] Selected event has no ID to open\n", - color.Yellow("WARN"), - ) - return - } - - // Build event URL based on project mode - var eventURL string - if p.cfg.ProjectMode == "console" { - eventURL = p.cfg.ConsoleBaseURL + "/?event_id=" + eventID - } else { - eventURL = p.cfg.DashboardBaseURL + "/events/" + eventID - } - - // Open URL in browser - err := p.openBrowser(eventURL) - if err != nil { - color := ansi.Color(os.Stdout) - p.safePrintf("[%s] Failed to open browser: %v\n", - color.Red("ERROR").Bold(), - err, - ) - return - } -} - -func (p *Proxy) openBrowser(url string) error { - var cmd string - var args []string - - switch runtime.GOOS { - case "windows": - cmd = "cmd" - args = []string{"/c", "start", url} - case "darwin": - cmd = "open" - args = []string{url} - default: // "linux", "freebsd", "openbsd", "netbsd" - cmd = "xdg-open" - args = []string{url} - } - - return exec.Command(cmd, args...).Start() -} - -// enterDetailsView shows event details using less pager for scrolling -func (p *Proxy) enterDetailsView() { - if p.selectedEventIndex < 0 || p.selectedEventIndex >= len(p.eventHistory) { - return - } - - selectedEvent := p.eventHistory[p.selectedEventIndex] - if selectedEvent.Data == nil { - return - } - - p.terminalMutex.Lock() - - // Temporarily restore normal terminal mode - if p.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), p.rawModeState) - } - - // Build the details content - webhookEvent := selectedEvent.Data - color := ansi.Color(os.Stdout) - var content strings.Builder - - // Header with navigation hints - content.WriteString(ansi.Bold("Event Details")) - content.WriteString("\n") - content.WriteString(ansi.Faint("| Press 'q' to return to events • Use arrow keys/Page Up/Down to scroll")) - content.WriteString("\n") - content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) - content.WriteString("\n\n") - - // Event metadata - timestampStr := selectedEvent.Time.Format(timeLayout) - statusIcon := color.Green("āœ“") - statusText := "succeeded" - statusDisplay := color.Bold(fmt.Sprintf("%d", selectedEvent.Status)) - if !selectedEvent.Success { - statusIcon = color.Red("x").Bold() - statusText = "failed" - if selectedEvent.Status == 0 { - statusDisplay = color.Bold("error") - } - } - - content.WriteString(fmt.Sprintf("%s Event %s with status %s at %s\n", statusIcon, statusText, statusDisplay, ansi.Faint(timestampStr))) - content.WriteString("\n") - - // Dashboard URL - dashboardURL := p.cfg.DashboardBaseURL - if p.cfg.ProjectID != "" { - dashboardURL += "/cli/events/" + selectedEvent.ID - } - if p.cfg.ProjectMode == "console" { - dashboardURL = p.cfg.ConsoleBaseURL - } - content.WriteString(fmt.Sprintf("%s %s\n", ansi.Faint("šŸ”—"), ansi.Faint(dashboardURL))) - content.WriteString("\n") - content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) - content.WriteString("\n\n") - - // Request section - content.WriteString(ansi.Bold("Request")) - content.WriteString("\n\n") - // Construct the full URL with query params the same way as in processAttempt - fullURL := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path - content.WriteString(fmt.Sprintf("%s %s\n", color.Bold(webhookEvent.Body.Request.Method), fullURL)) - content.WriteString("\n") - content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) - content.WriteString("\n\n") - - // Headers section - if len(webhookEvent.Body.Request.Headers) > 0 { - content.WriteString(ansi.Bold("Headers")) - content.WriteString("\n\n") - - var headers map[string]json.RawMessage - if err := json.Unmarshal(webhookEvent.Body.Request.Headers, &headers); err == nil { - keys := make([]string, 0, len(headers)) - for key := range headers { - keys = append(keys, key) - } - sort.Strings(keys) - - for _, key := range keys { - unquoted, _ := strconv.Unquote(string(headers[key])) - content.WriteString(fmt.Sprintf("%s: %s\n", ansi.Faint(strings.ToLower(key)), unquoted)) - } - } - content.WriteString("\n") - content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) - content.WriteString("\n\n") - } - - // Body section - if len(webhookEvent.Body.Request.DataString) > 0 { - content.WriteString(ansi.Bold("Body")) - content.WriteString("\n\n") - - var bodyData interface{} - if err := json.Unmarshal([]byte(webhookEvent.Body.Request.DataString), &bodyData); err == nil { - prettyJSON, err := json.MarshalIndent(bodyData, "", " ") - if err == nil { - content.WriteString(string(prettyJSON)) - content.WriteString("\n") - } - } else { - content.WriteString(webhookEvent.Body.Request.DataString) - content.WriteString("\n") - } - } - - // Footer - content.WriteString("\n") - content.WriteString(fmt.Sprintf("%s Use arrow keys/Page Up/Down to scroll • Press 'q' to return to events\n", ansi.Faint("āŒØļø"))) - - // Set the flag before launching pager - p.showingDetails = true - - p.terminalMutex.Unlock() - - // Use less with standard options - // Note: Custom key bindings are unreliable, so we stick with 'q' to quit - // We use echo to pipe content to less, which allows less to read keyboard from terminal - - cmd := exec.Command("sh", "-c", "less -R") - - // Create stdin pipe to send content - stdinPipe, err := cmd.StdinPipe() - if err != nil { - // Fallback: print directly - p.terminalMutex.Lock() - fmt.Print(content.String()) - p.showingDetails = false - if p.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } - p.terminalMutex.Unlock() - return - } - - // Connect to terminal - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - // Start less - if err := cmd.Start(); err != nil { - // Fallback: print directly - p.terminalMutex.Lock() - fmt.Print(content.String()) - p.showingDetails = false - if p.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } - p.terminalMutex.Unlock() - return - } - - // Write content to less - stdinPipe.Write([]byte(content.String())) - stdinPipe.Close() - - // Wait for less to exit - cmd.Wait() - - // After pager exits, restore state - p.terminalMutex.Lock() - p.showingDetails = false - - // Re-enable raw mode - if p.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } - p.terminalMutex.Unlock() -} - -// exitDetailsView is called when user presses 'd' or 'q' while in details view -func (p *Proxy) exitDetailsView() { - p.showingDetails = false -} - // // Public functions // @@ -1499,12 +508,29 @@ func New(cfg *Config, connections []*hookdecksdk.Connection) *Proxy { cfg.Log = &log.Logger{Out: ioutil.Discard} } + eventHistory := NewEventHistory() + ui := NewTerminalUI(eventHistory) + p := &Proxy{ - cfg: cfg, - connections: connections, - connectionTimer: time.NewTimer(0), // Defaults to no delay - selectedEventIndex: -1, // Initialize to invalid index - } + cfg: cfg, + connections: connections, + connectionTimer: time.NewTimer(0), // Defaults to no delay + eventHistory: eventHistory, + ui: ui, + } + + // Create event actions handler + p.eventActions = NewEventActions(cfg, eventHistory, ui) + + // Create keyboard handler and set up callbacks + p.keyboardHandler = NewKeyboardHandler(ui, &p.hasReceivedEvent, &p.isConnected, &p.showingDetails) + p.keyboardHandler.SetCallbacks( + p.navigateEvents, + p.eventActions.RetrySelectedEvent, + p.eventActions.OpenSelectedEventURL, + p.toggleDetailsView, + p.quit, + ) return p } diff --git a/pkg/proxy/terminal_ui.go b/pkg/proxy/terminal_ui.go new file mode 100644 index 0000000..4f476fa --- /dev/null +++ b/pkg/proxy/terminal_ui.go @@ -0,0 +1,456 @@ +package proxy + +import ( + "fmt" + "os" + "sync" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "golang.org/x/term" +) + +// TerminalUI handles all terminal rendering and display logic +type TerminalUI struct { + terminalMutex sync.Mutex + rawModeState *term.State + statusLineShown bool + waitingAnimationFrame int + eventHistory *EventHistory +} + +// NewTerminalUI creates a new TerminalUI instance +func NewTerminalUI(eventHistory *EventHistory) *TerminalUI { + return &TerminalUI{ + eventHistory: eventHistory, + } +} + +// SetRawModeState stores the terminal's raw mode state for safe printing +func (ui *TerminalUI) SetRawModeState(state *term.State) { + ui.rawModeState = state +} + +// SafePrintf temporarily disables raw mode, prints the message, then re-enables raw mode +func (ui *TerminalUI) SafePrintf(format string, args ...interface{}) { + ui.terminalMutex.Lock() + defer ui.terminalMutex.Unlock() + + // Temporarily restore normal terminal mode for printing + if ui.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), ui.rawModeState) + } + + // Print the message + fmt.Printf(format, args...) + + // Re-enable raw mode + if ui.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } +} + +// calculateEventLines calculates how many terminal lines an event log occupies +// accounting for line wrapping based on terminal width +func (ui *TerminalUI) calculateEventLines(logLine string) int { + // Get terminal width + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + width = 80 // Default fallback + } + + // Add 2 for the potential "> " prefix or " " indentation + lineLength := len(logLine) + 2 + + // Calculate how many lines this will occupy + lines := (lineLength + width - 1) / width // Ceiling division + if lines < 1 { + lines = 1 + } + return lines +} + +// BuildStatusMessage generates the status line message based on the current state +func (ui *TerminalUI) BuildStatusMessage(hasReceivedEvent bool) string { + color := ansi.Color(os.Stdout) + + // If no events received yet, show waiting animation + if !hasReceivedEvent { + var dot string + if ui.waitingAnimationFrame%2 == 0 { + dot = fmt.Sprintf("%s", color.Green("ā—")) + } else { + dot = fmt.Sprintf("%s", color.Green("ā—‹")) + } + ui.waitingAnimationFrame++ + return fmt.Sprintf("%s Connected. Waiting for events...", dot) + } + + // Get the selected event to show its status + selectedEvent := ui.eventHistory.GetSelectedEvent() + if selectedEvent == nil { + return "" // No events available + } + + // If user has navigated, show "Selected event" + if ui.eventHistory.IsUserNavigated() { + if selectedEvent.Success { + return fmt.Sprintf("> %s Selected event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", + color.Green("āœ“"), selectedEvent.Status) + } else { + if selectedEvent.Status == 0 { + return fmt.Sprintf("> %s Selected event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data & • [Ctrl+C] Quit", + color.Red("x").Bold()) + } else { + return fmt.Sprintf("> %s Selected event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", + color.Red("x").Bold(), selectedEvent.Status) + } + } + } + + // Auto-selecting latest event - show "Last event" + if selectedEvent.Success { + return fmt.Sprintf("> %s Last event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", + color.Green("āœ“"), selectedEvent.Status) + } else { + if selectedEvent.Status == 0 { + return fmt.Sprintf("> %s Last event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit", + color.Red("x").Bold()) + } else { + return fmt.Sprintf("> %s Last event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit", + color.Red("x").Bold(), selectedEvent.Status) + } + } +} + +// UpdateStatusLine updates the bottom status line with the latest event information +func (ui *TerminalUI) UpdateStatusLine(hasReceivedEvent bool) { + ui.terminalMutex.Lock() + defer ui.terminalMutex.Unlock() + + // Only update if we haven't received any events yet (just the waiting animation) + if hasReceivedEvent { + return + } + + // Temporarily restore normal terminal mode for printing + if ui.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), ui.rawModeState) + } + + // Generate status message (waiting animation) + statusMsg := ui.BuildStatusMessage(hasReceivedEvent) + + if ui.statusLineShown { + // If we've shown a status before, move up one line and clear it + fmt.Printf("\033[1A\033[2K\r%s\n", statusMsg) + } else { + // First time showing status + fmt.Printf("%s\n", statusMsg) + ui.statusLineShown = true + } + + // Re-enable raw mode + if ui.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } +} + +// PrintEventAndUpdateStatus prints the event log and updates the status line in one operation +func (ui *TerminalUI) PrintEventAndUpdateStatus(eventInfo EventInfo, hasReceivedEvent bool) { + ui.terminalMutex.Lock() + defer ui.terminalMutex.Unlock() + + // Check if this is the 11th event - need to add "Events" title before the first historical event + isEleventhEvent := ui.eventHistory.Count() == maxNavigableEvents && !ui.eventHistory.IsEventsTitleDisplayed() + + // If this is the 11th event, print the "Events" title now (before adding the event) + if isEleventhEvent { + // Temporarily restore normal terminal mode for printing + if ui.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), ui.rawModeState) + } + + // Move up to clear status line and blank line + fmt.Print("\033[2A\033[2K\r\033[1B\033[2K\r\033[1A") + + // Print "Events" title with newline above + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n\n", color.Faint("Events")) + + // Print blank line and status that will be replaced + fmt.Println() + statusMsg := fmt.Sprintf("%s Adding...", color.Faint("ā—")) + fmt.Printf("%s\n", statusMsg) + + ui.eventHistory.SetEventsTitleDisplayed(true) + + // Re-enable raw mode + if ui.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } + } + + // Check if any event will exit the navigable window when we add this new event + // We need to remove indentation from events becoming immutable + needToRedrawForExitingEvents := false + if ui.eventHistory.Count() >= maxNavigableEvents { + needToRedrawForExitingEvents = true + } + + // Check if we need to redraw due to selection changes + needToClearOldSelection := false + if ui.eventHistory.IsUserNavigated() && ui.eventHistory.Count() > 0 { + // Calculate what the navigable range will be after adding the new event + futureHistorySize := ui.eventHistory.Count() + 1 + futureNavigableStartIdx := futureHistorySize - maxNavigableEvents + if futureNavigableStartIdx < 0 { + futureNavigableStartIdx = 0 + } + + // If current selection will be outside future navigable range, we need to redraw + // (The selected event will be pinned in the display, breaking chronological order) + if ui.eventHistory.GetSelectedIndex() < futureNavigableStartIdx { + needToClearOldSelection = true + } + } + + // Redraw navigable window if events are exiting or selection is being cleared + // BUT skip if we just printed the Events title (11th event case) + if (needToRedrawForExitingEvents || needToClearOldSelection) && !isEleventhEvent { + // Temporarily restore normal terminal mode for printing + if ui.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), ui.rawModeState) + } + + events := ui.eventHistory.GetEvents() + selectedIndex := ui.eventHistory.GetSelectedIndex() + + // Calculate current navigable window + currentNavigableStartIdx := len(events) - maxNavigableEvents + if currentNavigableStartIdx < 0 { + currentNavigableStartIdx = 0 + } + currentNumNavigableEvents := len(events) - currentNavigableStartIdx + + // Calculate future navigable window to determine which event will become immutable + futureHistorySize := len(events) + 1 + futureNavigableStartIdx := futureHistorySize - maxNavigableEvents + if futureNavigableStartIdx < 0 { + futureNavigableStartIdx = 0 + } + + // Move cursor up and clear + // Account for: navigable events + separator (3 lines if present) + blank + status + linesToMoveUp := currentNumNavigableEvents + 2 // events + blank + status + // If we'll have a separator, add 3 more lines (blank line + "Recent events" + blank line) + if futureNavigableStartIdx > 0 { + linesToMoveUp += 3 + } + fmt.Printf("\033[%dA", linesToMoveUp) + fmt.Print("\033[J") + + // NOTE: We NEVER redraw the "Events" title - it was printed once and stays permanent + + // Redraw events + for i := currentNavigableStartIdx; i < len(events); i++ { + // Events that will become immutable (fall outside future navigable range) have no indentation + if i < futureNavigableStartIdx { + fmt.Printf("%s\n", events[i].LogLine) // No indentation + } else { + // Add "Latest events" separator before first navigable event + if i == futureNavigableStartIdx { + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n\n", color.Faint("Latest events (↑↓ to navigate)")) // Extra newline after separator + } + // Only indent selected event with ">", others have no indentation + if i == selectedIndex { + fmt.Printf("> %s\n", events[i].LogLine) // Selected + } else { + fmt.Printf("%s\n", events[i].LogLine) // No indentation + } + } + } + + // Blank line + fmt.Println() + + // Status message (will be replaced soon) + color := ansi.Color(os.Stdout) + statusMsg := fmt.Sprintf("%s Updating...", color.Faint("ā—")) + fmt.Printf("%s\n", statusMsg) + + // Re-enable raw mode + if ui.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } + } + + // Add event to history (includes all data from caller) + // Note: AddEvent handles deduplication, history size limits, and auto-selection + ui.eventHistory.AddEvent(eventInfo) + + // Temporarily restore normal terminal mode for printing + if ui.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), ui.rawModeState) + } + + events := ui.eventHistory.GetEvents() + selectedIndex := ui.eventHistory.GetSelectedIndex() + + // Calculate the navigable window (last 10 events) + navigableStartIdx := len(events) - maxNavigableEvents + if navigableStartIdx < 0 { + navigableStartIdx = 0 + } + numNavigableEvents := len(events) - navigableStartIdx + + // If we have multiple navigable events and auto-selecting, redraw navigable window + // Also redraw if user has navigated (to show pinned selection) + if numNavigableEvents > 1 && !ui.eventHistory.IsUserNavigated() { + // Auto-selecting mode: redraw to move selection to latest + // Calculate total terminal lines occupied by previous navigable events + totalEventLines := 0 + for i := navigableStartIdx; i < len(events)-1; i++ { + totalEventLines += ui.calculateEventLines(events[i].LogLine) + } + linesToMoveUp := totalEventLines + 2 // previous event lines + blank + status + fmt.Printf("\033[%dA", linesToMoveUp) + fmt.Print("\033[J") + + // Print navigable events with selection on the latest + for i := navigableStartIdx; i < len(events); i++ { + if i == selectedIndex { + fmt.Printf("> %s\n", events[i].LogLine) + } else { + fmt.Printf("%s\n", events[i].LogLine) // No indentation + } + } + } else if ui.eventHistory.IsUserNavigated() && numNavigableEvents > 1 { + // User has navigated: redraw to show pinned selected event + // Get the navigable events (includes pinned selected event if applicable) + navigableIndices := ui.eventHistory.GetNavigableEvents() + + // Calculate total terminal lines occupied by previous navigable events + totalEventLines := 0 + for i := 0; i < len(navigableIndices)-1; i++ { + totalEventLines += ui.calculateEventLines(events[navigableIndices[i]].LogLine) + } + linesToMoveUp := totalEventLines + 2 // previous event lines + blank + status + fmt.Printf("\033[%dA", linesToMoveUp) + fmt.Print("\033[J") + + // Print navigable events (including pinned event) with selection indicator + for _, idx := range navigableIndices { + if idx == selectedIndex { + fmt.Printf("> %s\n", events[idx].LogLine) + } else { + fmt.Printf("%s\n", events[idx].LogLine) + } + } + } else { + // First event - simple append + if ui.statusLineShown { + if len(events) == 1 { + // First event - only clear the "waiting" status line + fmt.Print("\033[1A\033[2K\r") + } else { + // Clear status line and blank line + fmt.Print("\033[2A\033[2K\r\033[1B\033[2K\r\033[1A") + } + } + + // Print the new event + newEventIndex := len(events) - 1 + // Only indent if selected, otherwise no indentation + if selectedIndex == newEventIndex { + fmt.Printf("> %s\n", events[newEventIndex].LogLine) + } else { + fmt.Printf("%s\n", events[newEventIndex].LogLine) // No indentation + } + } + + // Blank line + fmt.Println() + + // Generate and print status message + statusMsg := ui.BuildStatusMessage(hasReceivedEvent) + fmt.Printf("%s\n", statusMsg) + ui.statusLineShown = true + + // Re-enable raw mode + if ui.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } +} + +// RedrawEventsWithSelection updates the selection indicators without clearing the screen (only last 10 events) +func (ui *TerminalUI) RedrawEventsWithSelection(hasReceivedEvent bool) { + if ui.eventHistory.Count() == 0 { + return + } + + ui.terminalMutex.Lock() + defer ui.terminalMutex.Unlock() + + // Temporarily restore normal terminal mode for printing + if ui.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), ui.rawModeState) + } + + events := ui.eventHistory.GetEvents() + selectedIndex := ui.eventHistory.GetSelectedIndex() + + // Get the navigable events (includes pinned selected event if applicable) + navigableIndices := ui.eventHistory.GetNavigableEvents() + + // Calculate the normal navigable start for determining if we need separator + normalNavigableStartIdx := len(events) - maxNavigableEvents + if normalNavigableStartIdx < 0 { + normalNavigableStartIdx = 0 + } + + // Calculate total terminal lines occupied by navigable events + totalEventLines := 0 + for _, idx := range navigableIndices { + totalEventLines += ui.calculateEventLines(events[idx].LogLine) + } + + // Move cursor up to start of navigable events and clear everything below + linesToMoveUp := totalEventLines + 2 // event lines + blank + status + // If we have a separator, add 3 more lines (blank line + "Latest events" + blank line) + if normalNavigableStartIdx > 0 { + linesToMoveUp += 3 + } + fmt.Printf("\033[%dA", linesToMoveUp) + fmt.Print("\033[J") + + // NOTE: We NEVER redraw the "Events" title - it was printed once and stays permanent + + // Add separator if there are historical events + if normalNavigableStartIdx > 0 { + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n\n", color.Faint("Latest events (↑↓ to navigate)")) // Extra newline after separator + } + + // Print the navigable events (including pinned event if applicable) with selection indicator + for _, idx := range navigableIndices { + if idx == selectedIndex { + fmt.Printf("> %s\n", events[idx].LogLine) // Selected event with > + } else { + fmt.Printf("%s\n", events[idx].LogLine) // No indentation + } + } + + // Add a newline before the status line + fmt.Println() + + // Generate and print the status message for the selected event + statusMsg := ui.BuildStatusMessage(hasReceivedEvent) + fmt.Printf("%s\n", statusMsg) + ui.statusLineShown = true + + // Re-enable raw mode + if ui.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } +} From 184b48e9bfe6d789ffe935b87ed1fa6b82dac2ed Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Wed, 8 Oct 2025 14:26:25 -0400 Subject: [PATCH 16/22] feat: Improve event details display --- pkg/proxy/event_actions.go | 156 ++++++++++++++++++++++++------------- pkg/proxy/event_history.go | 5 ++ pkg/proxy/proxy.go | 75 +++++++++++------- pkg/proxy/terminal_ui.go | 93 +++++++++++++++++++++- 4 files changed, 246 insertions(+), 83 deletions(-) diff --git a/pkg/proxy/event_actions.go b/pkg/proxy/event_actions.go index a794e0c..26a1eee 100644 --- a/pkg/proxy/event_actions.go +++ b/pkg/proxy/event_actions.go @@ -159,36 +159,7 @@ func (ea *EventActions) ShowEventDetails() (bool, error) { // Header with navigation hints content.WriteString(ansi.Bold("Event Details")) content.WriteString("\n") - content.WriteString(ansi.Faint("| Press 'q' to return to events • Use arrow keys/Page Up/Down to scroll")) - content.WriteString("\n") - content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) - content.WriteString("\n\n") - - // Event metadata - timestampStr := selectedEvent.Time.Format(timeLayout) - statusIcon := color.Green("āœ“") - statusText := "succeeded" - statusDisplay := color.Bold(fmt.Sprintf("%d", selectedEvent.Status)) - if !selectedEvent.Success { - statusIcon = color.Red("x").Bold() - statusText = "failed" - if selectedEvent.Status == 0 { - statusDisplay = color.Bold("error") - } - } - - content.WriteString(fmt.Sprintf("%s Event %s with status %s at %s\n", statusIcon, statusText, statusDisplay, ansi.Faint(timestampStr))) - content.WriteString("\n") - - // Dashboard URL - dashboardURL := ea.cfg.DashboardBaseURL - if ea.cfg.ProjectID != "" { - dashboardURL += "/cli/events/" + selectedEvent.ID - } - if ea.cfg.ProjectMode == "console" { - dashboardURL = ea.cfg.ConsoleBaseURL - } - content.WriteString(fmt.Sprintf("%s %s\n", ansi.Faint("šŸ”—"), ansi.Faint(dashboardURL))) + content.WriteString(ansi.Faint("Press 'q' to return to events • Use arrow keys/Page Up/Down to scroll")) content.WriteString("\n") content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) content.WriteString("\n\n") @@ -198,10 +169,7 @@ func (ea *EventActions) ShowEventDetails() (bool, error) { content.WriteString("\n\n") // Construct the full URL with query params fullURL := ea.cfg.URL.Scheme + "://" + ea.cfg.URL.Host + ea.cfg.URL.Path + webhookEvent.Body.Path - content.WriteString(fmt.Sprintf("%s %s\n", color.Bold(webhookEvent.Body.Request.Method), fullURL)) - content.WriteString("\n") - content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) - content.WriteString("\n\n") + content.WriteString(fmt.Sprintf("%s %s\n\n", color.Bold(webhookEvent.Body.Request.Method), fullURL)) // Headers section if len(webhookEvent.Body.Request.Headers) > 0 { @@ -222,8 +190,6 @@ func (ea *EventActions) ShowEventDetails() (bool, error) { } } content.WriteString("\n") - content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) - content.WriteString("\n\n") } // Body section @@ -244,38 +210,122 @@ func (ea *EventActions) ShowEventDetails() (bool, error) { } } + // Response section + content.WriteString("\n") + content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) + content.WriteString("\n\n") + + // Check if this was an error (no response received) + if selectedEvent.ResponseStatus == 0 && selectedEvent.ResponseBody == "" { + // Request failed - no response received + content.WriteString(ansi.Bold("Response")) + content.WriteString("\n\n") + content.WriteString(color.Red("Request failed - no response received").String()) + content.WriteString("\n") + } else { + // Response header with status and duration + responseStatusText := fmt.Sprintf("%d", selectedEvent.ResponseStatus) + if selectedEvent.ResponseStatus >= 200 && selectedEvent.ResponseStatus < 300 { + responseStatusText = color.Green(responseStatusText).String() + } else if selectedEvent.ResponseStatus >= 400 { + responseStatusText = color.Red(responseStatusText).String() + } else if selectedEvent.ResponseStatus >= 300 { + responseStatusText = color.Yellow(responseStatusText).String() + } + + durationMs := selectedEvent.ResponseDuration.Milliseconds() + content.WriteString(fmt.Sprintf("%s • %s • %dms\n\n", + ansi.Bold("Response"), + responseStatusText, + durationMs, + )) + + // Response headers section + if len(selectedEvent.ResponseHeaders) > 0 { + content.WriteString(ansi.Bold("Headers")) + content.WriteString("\n\n") + + // Sort header keys for consistent display + keys := make([]string, 0, len(selectedEvent.ResponseHeaders)) + for key := range selectedEvent.ResponseHeaders { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + values := selectedEvent.ResponseHeaders[key] + // Join multiple values with comma + content.WriteString(fmt.Sprintf("%s: %s\n", + ansi.Faint(strings.ToLower(key)), + strings.Join(values, ", "), + )) + } + content.WriteString("\n") + } + + // Response body section + if len(selectedEvent.ResponseBody) > 0 { + content.WriteString(ansi.Bold("Body")) + content.WriteString("\n\n") + + var bodyData interface{} + if err := json.Unmarshal([]byte(selectedEvent.ResponseBody), &bodyData); err == nil { + prettyJSON, err := json.MarshalIndent(bodyData, "", " ") + if err == nil { + content.WriteString(string(prettyJSON)) + content.WriteString("\n") + } + } else { + content.WriteString(selectedEvent.ResponseBody) + content.WriteString("\n") + } + } else { + content.WriteString(ansi.Faint("(empty)")) + content.WriteString("\n\n") + } + } + // Footer content.WriteString("\n") - content.WriteString(fmt.Sprintf("%s Use arrow keys/Page Up/Down to scroll • Press 'q' to return to events\n", ansi.Faint("āŒØļø"))) + content.WriteString(ansi.Faint("Press 'q' to return to events • Use arrow keys/Page Up/Down to scroll")) + content.WriteString("\n") - // Use less with standard options - cmd := exec.Command("sh", "-c", "less -R") + // Use less with options: + // -R: Allow ANSI color codes + // (alternate screen is enabled by default, which restores terminal content on exit) + cmd := exec.Command("less", "-R") - // Create stdin pipe to send content - stdinPipe, err := cmd.StdinPipe() + // Connect less to the terminal for interactive control + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Create a temporary file for the content (less reads from file, not pipe) + tmpfile, err := os.CreateTemp("", "hookdeck-event-*.txt") if err != nil { // Fallback: print directly fmt.Print(content.String()) return false, nil } + defer os.Remove(tmpfile.Name()) - // Connect to terminal - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - // Start less - if err := cmd.Start(); err != nil { - // Fallback: print directly + // Write content to temp file + if _, err := tmpfile.Write([]byte(content.String())); err != nil { + tmpfile.Close() fmt.Print(content.String()) return false, nil } + tmpfile.Close() - // Write content to less - stdinPipe.Write([]byte(content.String())) - stdinPipe.Close() + // Point less to the temp file + cmd.Args = append(cmd.Args, tmpfile.Name()) - // Wait for less to exit - cmd.Wait() + // Run less and wait for it to exit (it takes over terminal control) + if err := cmd.Run(); err != nil { + // Fallback: print directly + fmt.Print(content.String()) + return false, nil + } return true, nil } diff --git a/pkg/proxy/event_history.go b/pkg/proxy/event_history.go index 9bfa4fa..1ac4a10 100644 --- a/pkg/proxy/event_history.go +++ b/pkg/proxy/event_history.go @@ -15,6 +15,11 @@ type EventInfo struct { Time time.Time Data *websocket.Attempt LogLine string + // Response data + ResponseStatus int + ResponseHeaders map[string][]string + ResponseBody string + ResponseDuration time.Duration } // EventHistory manages the history of events and navigation state diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index a2f2a81..c78eeb1 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -91,19 +91,23 @@ func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { } // printEventAndUpdateStatus prints the event log and updates the status line in one operation -func (p *Proxy) printEventAndUpdateStatus(eventID string, status int, success bool, eventTime time.Time, eventData *websocket.Attempt, eventLog string) { +func (p *Proxy) printEventAndUpdateStatus(eventID string, status int, success bool, eventTime time.Time, eventData *websocket.Attempt, eventLog string, responseStatus int, responseHeaders map[string][]string, responseBody string, responseDuration time.Duration) { // Create event info with all data passed as parameters (no shared state) eventInfo := EventInfo{ - ID: eventID, - Status: status, - Success: success, - Time: eventTime, - Data: eventData, - LogLine: eventLog, + ID: eventID, + Status: status, + Success: success, + Time: eventTime, + Data: eventData, + LogLine: eventLog, + ResponseStatus: responseStatus, + ResponseHeaders: responseHeaders, + ResponseBody: responseBody, + ResponseDuration: responseDuration, } - // Delegate rendering to UI - p.ui.PrintEventAndUpdateStatus(eventInfo, p.hasReceivedEvent) + // Delegate rendering to UI (pass showingDetails flag to block rendering while less is open) + p.ui.PrintEventAndUpdateStatus(eventInfo, p.hasReceivedEvent, p.showingDetails) } // startWaitingAnimation starts an animation for the waiting indicator @@ -140,9 +144,16 @@ func (p *Proxy) toggleDetailsView() { if p.showingDetails { p.showingDetails = false } else { + // Set flag BEFORE calling ShowEventDetails, since it blocks until less exits + p.showingDetails = true shown, _ := p.eventActions.ShowEventDetails() + // Reset flag after less exits + p.showingDetails = false + if shown { - p.showingDetails = true + // After less exits, we need to redraw the entire event list + // because less has taken over the screen + p.ui.RedrawAfterDetailsView(p.hasReceivedEvent) } } } @@ -399,6 +410,8 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { req.Body = ioutil.NopCloser(strings.NewReader(webhookEvent.Body.Request.DataString)) req.ContentLength = int64(len(webhookEvent.Body.Request.DataString)) + // Track request start time for duration calculation + requestStartTime := time.Now() res, err := client.Do(req) if err != nil { @@ -421,8 +434,8 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { } } - // Print the error and update status line with event-specific data - p.printEventAndUpdateStatus(eventID, 0, false, time.Now(), webhookEvent, errStr) + // Print the error and update status line with event-specific data (no response data for errors) + p.printEventAndUpdateStatus(eventID, 0, false, time.Now(), webhookEvent, errStr, 0, nil, "", 0) p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ @@ -433,12 +446,12 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { }, }}) } else { - p.processEndpointResponse(webhookEvent, res) + p.processEndpointResponse(webhookEvent, res, requestStartTime) } } } -func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *http.Response) { +func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *http.Response, requestStartTime time.Time) { eventTime := time.Now() localTime := eventTime.Format(timeLayout) color := ansi.Color(os.Stdout) @@ -460,18 +473,7 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h eventSuccess := resp.StatusCode >= 200 && resp.StatusCode < 300 eventID := webhookEvent.Body.EventID - // Mark as having received first event - if !p.hasReceivedEvent { - p.hasReceivedEvent = true - // Stop the waiting animation - if p.stopWaitingAnimation != nil { - p.stopWaitingAnimation <- true - } - } - - // Print the event log and update status line with event-specific data - p.printEventAndUpdateStatus(eventID, eventStatus, eventSuccess, eventTime, webhookEvent, outputStr) - + // Read response body buf, err := ioutil.ReadAll(resp.Body) if err != nil { errStr := fmt.Sprintf("%s [%s] Failed to read response from endpoint, error = %v\n", @@ -484,6 +486,27 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h return } + // Capture response data + responseStatus := resp.StatusCode + responseHeaders := make(map[string][]string) + for key, values := range resp.Header { + responseHeaders[key] = values + } + responseBody := string(buf) + responseDuration := eventTime.Sub(requestStartTime) + + // Mark as having received first event + if !p.hasReceivedEvent { + p.hasReceivedEvent = true + // Stop the waiting animation + if p.stopWaitingAnimation != nil { + p.stopWaitingAnimation <- true + } + } + + // Print the event log and update status line with event-specific data including response + p.printEventAndUpdateStatus(eventID, eventStatus, eventSuccess, eventTime, webhookEvent, outputStr, responseStatus, responseHeaders, responseBody, responseDuration) + if p.webSocketClient != nil { p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ AttemptResponse: &websocket.AttemptResponse{ diff --git a/pkg/proxy/terminal_ui.go b/pkg/proxy/terminal_ui.go index 4f476fa..93e8347 100644 --- a/pkg/proxy/terminal_ui.go +++ b/pkg/proxy/terminal_ui.go @@ -156,10 +156,18 @@ func (ui *TerminalUI) UpdateStatusLine(hasReceivedEvent bool) { } // PrintEventAndUpdateStatus prints the event log and updates the status line in one operation -func (ui *TerminalUI) PrintEventAndUpdateStatus(eventInfo EventInfo, hasReceivedEvent bool) { +func (ui *TerminalUI) PrintEventAndUpdateStatus(eventInfo EventInfo, hasReceivedEvent bool, showingDetails bool) { ui.terminalMutex.Lock() defer ui.terminalMutex.Unlock() + // Always add event to history (so it's available when returning from details view) + ui.eventHistory.AddEvent(eventInfo) + + // Skip all terminal rendering if details view is showing (less has control of the screen) + if showingDetails { + return + } + // Check if this is the 11th event - need to add "Events" title before the first historical event isEleventhEvent := ui.eventHistory.Count() == maxNavigableEvents && !ui.eventHistory.IsEventsTitleDisplayed() @@ -285,9 +293,8 @@ func (ui *TerminalUI) PrintEventAndUpdateStatus(eventInfo EventInfo, hasReceived } } - // Add event to history (includes all data from caller) - // Note: AddEvent handles deduplication, history size limits, and auto-selection - ui.eventHistory.AddEvent(eventInfo) + // Note: Event was already added to history at the start of this function + // (before the showingDetails check, so events are still tracked while viewing details) // Temporarily restore normal terminal mode for printing if ui.rawModeState != nil { @@ -383,6 +390,84 @@ func (ui *TerminalUI) PrintEventAndUpdateStatus(eventInfo EventInfo, hasReceived } } +// RedrawAfterDetailsView redraws the event list after returning from the details view +// less uses alternate screen, so the original screen content should be restored automatically +// We just need to redraw the events that may have arrived while viewing details +func (ui *TerminalUI) RedrawAfterDetailsView(hasReceivedEvent bool) { + ui.terminalMutex.Lock() + defer ui.terminalMutex.Unlock() + + // Temporarily restore normal terminal mode for printing + if ui.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), ui.rawModeState) + } + + // After less exits, the terminal should have restored the original screen + // We need to redraw the entire navigable events section since events may have arrived + + events := ui.eventHistory.GetEvents() + if len(events) == 0 { + // Re-enable raw mode + if ui.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } + return + } + + selectedIndex := ui.eventHistory.GetSelectedIndex() + + // Get the navigable events (includes pinned selected event if applicable) + navigableIndices := ui.eventHistory.GetNavigableEvents() + + // Calculate the normal navigable start for determining if we need separator + normalNavigableStartIdx := len(events) - maxNavigableEvents + if normalNavigableStartIdx < 0 { + normalNavigableStartIdx = 0 + } + + // Calculate how many lines to move up: navigable events + separator (if present) + blank + status + totalEventLines := 0 + for _, idx := range navigableIndices { + totalEventLines += ui.calculateEventLines(events[idx].LogLine) + } + linesToMoveUp := totalEventLines + 2 // event lines + blank + status + if normalNavigableStartIdx > 0 { + linesToMoveUp += 3 // blank + "Latest events" + blank + } + + // Move cursor up and clear everything below + fmt.Printf("\033[%dA", linesToMoveUp) + fmt.Print("\033[J") + + // Add separator if there are historical events + if normalNavigableStartIdx > 0 { + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n\n", color.Faint("Latest events (↑↓ to navigate)")) + } + + // Print the navigable events with selection indicator + for _, idx := range navigableIndices { + if idx == selectedIndex { + fmt.Printf("> %s\n", events[idx].LogLine) // Selected event with > + } else { + fmt.Printf("%s\n", events[idx].LogLine) // No indentation + } + } + + // Add a newline before the status line + fmt.Println() + + // Generate and print the status message for the selected event + statusMsg := ui.BuildStatusMessage(hasReceivedEvent) + fmt.Printf("%s\n", statusMsg) + ui.statusLineShown = true + + // Re-enable raw mode + if ui.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } +} + // RedrawEventsWithSelection updates the selection indicators without clearing the screen (only last 10 events) func (ui *TerminalUI) RedrawEventsWithSelection(hasReceivedEvent bool) { if ui.eventHistory.Count() == 0 { From 7e31c529dcf9b1a8e700710a4a31750383e1a7c0 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Wed, 8 Oct 2025 16:15:39 -0400 Subject: [PATCH 17/22] chore: Fix various issues --- pkg/ansi/ansi.go | 8 ++++ pkg/proxy/event_actions.go | 46 +++++++++++++++------- pkg/proxy/keyboard.go | 78 ++++++++++++++++++++++++++++++++------ pkg/proxy/proxy.go | 44 +++++++++++++-------- pkg/proxy/terminal_ui.go | 19 +++++++++- 5 files changed, 153 insertions(+), 42 deletions(-) diff --git a/pkg/ansi/ansi.go b/pkg/ansi/ansi.go index 15980a4..7b6a754 100644 --- a/pkg/ansi/ansi.go +++ b/pkg/ansi/ansi.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "os" + "regexp" "runtime" "time" @@ -13,6 +14,8 @@ import ( "golang.org/x/term" ) +var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + var darkTerminalStyle = &pretty.Style{ Key: [2]string{"\x1B[34m", "\x1B[0m"}, String: [2]string{"\x1B[30m", "\x1B[0m"}, @@ -46,6 +49,11 @@ func Bold(text string) string { return color.Sprintf(color.Bold(text)) } +// StripANSI removes all ANSI escape sequences from a string +func StripANSI(text string) string { + return ansiRegex.ReplaceAllString(text, "") +} + // Color returns an aurora.Aurora instance with colors enabled or disabled // depending on whether the writer supports colors. func Color(w io.Writer) aurora.Aurora { diff --git a/pkg/proxy/event_actions.go b/pkg/proxy/event_actions.go index 26a1eee..888f9c1 100644 --- a/pkg/proxy/event_actions.go +++ b/pkg/proxy/event_actions.go @@ -290,17 +290,7 @@ func (ea *EventActions) ShowEventDetails() (bool, error) { content.WriteString(ansi.Faint("Press 'q' to return to events • Use arrow keys/Page Up/Down to scroll")) content.WriteString("\n") - // Use less with options: - // -R: Allow ANSI color codes - // (alternate screen is enabled by default, which restores terminal content on exit) - cmd := exec.Command("less", "-R") - - // Connect less to the terminal for interactive control - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - // Create a temporary file for the content (less reads from file, not pipe) + // Create a temporary file for the content tmpfile, err := os.CreateTemp("", "hookdeck-event-*.txt") if err != nil { // Fallback: print directly @@ -317,11 +307,39 @@ func (ea *EventActions) ShowEventDetails() (bool, error) { } tmpfile.Close() - // Point less to the temp file - cmd.Args = append(cmd.Args, tmpfile.Name()) + // Use less with options: + // -R: Allow ANSI color codes + // -P: Custom prompt to hide filename (show blank or custom message) + cmd := exec.Command("less", "-R", "-P?eEND:.", tmpfile.Name()) + + // CRITICAL: Restore normal terminal mode BEFORE opening /dev/tty + // Our keyboard handler has put stdin in raw mode, and /dev/tty shares the same terminal + // We need to restore normal mode so less can properly initialize its terminal handling + ea.ui.TemporarilyRestoreNormalMode() + + // CRITICAL: Open /dev/tty directly so less doesn't use stdin (which our keyboard handler is reading from) + // This gives less exclusive terminal access and prevents our keyboard handler from seeing its input + tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + // Fallback: use stdin (but this means keyboard handler might see input) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } else { + defer tty.Close() + // Give less exclusive access via /dev/tty + cmd.Stdin = tty + cmd.Stdout = tty + cmd.Stderr = tty + } // Run less and wait for it to exit (it takes over terminal control) - if err := cmd.Run(); err != nil { + err = cmd.Run() + + // Re-enable raw mode after less exits + ea.ui.ReEnableRawMode() + + if err != nil { // Fallback: print directly fmt.Print(content.String()) return false, nil diff --git a/pkg/proxy/keyboard.go b/pkg/proxy/keyboard.go index 73b0355..41b07c4 100644 --- a/pkg/proxy/keyboard.go +++ b/pkg/proxy/keyboard.go @@ -3,6 +3,7 @@ package proxy import ( "context" "os" + "sync" log "github.com/sirupsen/logrus" "golang.org/x/term" @@ -14,12 +15,15 @@ type KeyboardHandler struct { hasReceivedEvent *bool isConnected *bool showingDetails *bool + paused bool // Flag to pause input processing + pauseMutex sync.Mutex + inputCh chan []byte // Channel for buffered keyboard input // Callbacks for actions - onNavigate func(direction int) - onRetry func() - onOpen func() + onNavigate func(direction int) + onRetry func() + onOpen func() onToggleDetails func() - onQuit func() + onQuit func() } // NewKeyboardHandler creates a new KeyboardHandler instance @@ -47,6 +51,45 @@ func (kh *KeyboardHandler) SetCallbacks( kh.onQuit = onQuit } +// Pause temporarily stops processing keyboard input (while less is running) +func (kh *KeyboardHandler) Pause() { + kh.pauseMutex.Lock() + defer kh.pauseMutex.Unlock() + kh.paused = true + log.WithField("prefix", "KeyboardHandler.Pause").Debug("Keyboard input paused") +} + +// Resume resumes processing keyboard input (after less exits) +func (kh *KeyboardHandler) Resume() { + kh.pauseMutex.Lock() + defer kh.pauseMutex.Unlock() + kh.paused = false + log.WithField("prefix", "KeyboardHandler.Resume").Debug("Keyboard input resumed") +} + +// DrainBufferedInput discards any input that was buffered while paused +// This should be called after less exits but before Resume() to prevent +// keypresses meant for less from being processed by the app +func (kh *KeyboardHandler) DrainBufferedInput() { + if kh.inputCh == nil { + return + } + // Drain the channel non-blockingly + drained := 0 + for { + select { + case <-kh.inputCh: + drained++ + default: + // Channel is empty + if drained > 0 { + log.WithField("prefix", "KeyboardHandler.DrainBufferedInput").Debugf("Drained %d buffered inputs", drained) + } + return + } + } +} + // Start begins listening for keyboard input func (kh *KeyboardHandler) Start(ctx context.Context) { // Check if we're in a terminal @@ -67,12 +110,12 @@ func (kh *KeyboardHandler) Start(ctx context.Context) { // Ensure we restore terminal state when this goroutine exits defer term.Restore(int(os.Stdin.Fd()), oldState) - // Create a buffered channel for reading stdin - inputCh := make(chan []byte, 1) + // Create a buffered channel for reading stdin and store it as a field + kh.inputCh = make(chan []byte, 1) // Start a separate goroutine to read from stdin go func() { - defer close(inputCh) + defer close(kh.inputCh) buf := make([]byte, 3) // Buffer for escape sequences for { select { @@ -89,7 +132,7 @@ func (kh *KeyboardHandler) Start(ctx context.Context) { continue } select { - case inputCh <- buf[:n]: + case kh.inputCh <- buf[:n]: case <-ctx.Done(): return } @@ -102,7 +145,7 @@ func (kh *KeyboardHandler) Start(ctx context.Context) { select { case <-ctx.Done(): return - case input, ok := <-inputCh: + case input, ok := <-kh.inputCh: if !ok { return } @@ -120,6 +163,17 @@ func (kh *KeyboardHandler) processInput(input []byte) { return } + // Check if input processing is paused (e.g., while less is running) + kh.pauseMutex.Lock() + paused := kh.paused + kh.pauseMutex.Unlock() + + if paused { + // Discard all input while paused + log.WithField("prefix", "KeyboardHandler.processInput").Debugf("Discarding input while paused: %v", input) + return + } + // Handle single character keys if len(input) == 1 { switch input[0] { @@ -164,12 +218,12 @@ func (kh *KeyboardHandler) processInput(input []byte) { kh.onRetry() } case 0x6F, 0x4F: // 'o' or 'O' - if kh.onOpen != nil { + if !*kh.showingDetails && kh.onOpen != nil { kh.onOpen() } case 0x64, 0x44: // 'd' or 'D' - // Toggle alternate screen details view - if kh.onToggleDetails != nil { + // Toggle alternate screen details view (but not while already showing) + if !*kh.showingDetails && kh.onToggleDetails != nil { kh.onToggleDetails() } } diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index c78eeb1..329a82a 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -139,22 +139,30 @@ func (p *Proxy) quit() { proc.Signal(os.Interrupt) } -// toggleDetailsView toggles between showing and hiding event details +// toggleDetailsView shows event details (blocking until user exits less with 'q') func (p *Proxy) toggleDetailsView() { - if p.showingDetails { - p.showingDetails = false - } else { - // Set flag BEFORE calling ShowEventDetails, since it blocks until less exits - p.showingDetails = true - shown, _ := p.eventActions.ShowEventDetails() - // Reset flag after less exits + // Set flag BEFORE calling ShowEventDetails, since it blocks until less exits + p.showingDetails = true + + // Pause keyboard handler to prevent it from processing keypresses meant for less + p.keyboardHandler.Pause() + + // Ensure cleanup happens after less exits + defer func() { + // Drain any buffered input that was meant for less but leaked to our keyboard handler + p.keyboardHandler.DrainBufferedInput() + + // Resume normal keyboard processing + p.keyboardHandler.Resume() p.showingDetails = false + }() - if shown { - // After less exits, we need to redraw the entire event list - // because less has taken over the screen - p.ui.RedrawAfterDetailsView(p.hasReceivedEvent) - } + shown, _ := p.eventActions.ShowEventDetails() + + if shown { + // After less exits, we need to redraw the entire event list + // because less has taken over the screen + p.ui.RedrawAfterDetailsView(p.hasReceivedEvent) } } @@ -412,6 +420,7 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { // Track request start time for duration calculation requestStartTime := time.Now() + res, err := client.Do(req) if err != nil { @@ -459,11 +468,17 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h if p.cfg.ProjectMode == "console" { url = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID } - outputStr := fmt.Sprintf("%s [%d] %s %s %s %s", + + // Calculate response duration + responseDuration := eventTime.Sub(requestStartTime) + durationMs := responseDuration.Milliseconds() + + outputStr := fmt.Sprintf("%s [%d] %s %s %s %s %s", color.Faint(localTime), ansi.ColorizeStatus(resp.StatusCode), resp.Request.Method, resp.Request.URL, + color.Faint(fmt.Sprintf("(%dms)", durationMs)), color.Faint("→"), color.Faint(url), ) @@ -493,7 +508,6 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h responseHeaders[key] = values } responseBody := string(buf) - responseDuration := eventTime.Sub(requestStartTime) // Mark as having received first event if !p.hasReceivedEvent { diff --git a/pkg/proxy/terminal_ui.go b/pkg/proxy/terminal_ui.go index 93e8347..4a460a7 100644 --- a/pkg/proxy/terminal_ui.go +++ b/pkg/proxy/terminal_ui.go @@ -30,6 +30,20 @@ func (ui *TerminalUI) SetRawModeState(state *term.State) { ui.rawModeState = state } +// TemporarilyRestoreNormalMode restores normal terminal mode (call before launching external programs like less) +func (ui *TerminalUI) TemporarilyRestoreNormalMode() { + if ui.rawModeState != nil { + term.Restore(int(os.Stdin.Fd()), ui.rawModeState) + } +} + +// ReEnableRawMode re-enables raw mode (call after external programs exit) +func (ui *TerminalUI) ReEnableRawMode() { + if ui.rawModeState != nil { + term.MakeRaw(int(os.Stdin.Fd())) + } +} + // SafePrintf temporarily disables raw mode, prints the message, then re-enables raw mode func (ui *TerminalUI) SafePrintf(format string, args ...interface{}) { ui.terminalMutex.Lock() @@ -58,8 +72,11 @@ func (ui *TerminalUI) calculateEventLines(logLine string) int { width = 80 // Default fallback } + // Strip ANSI codes to get actual visual length + visualLine := ansi.StripANSI(logLine) + // Add 2 for the potential "> " prefix or " " indentation - lineLength := len(logLine) + 2 + lineLength := len(visualLine) + 2 // Calculate how many lines this will occupy lines := (lineLength + width - 1) / width // Ceiling division From b4aa5392607400ae53c558e53f3098f6d983e162 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Wed, 8 Oct 2025 16:46:52 -0400 Subject: [PATCH 18/22] feat: Add output mode control --- README.md | 52 ++++++++- pkg/cmd/listen.go | 24 ++++- pkg/listen/listen.go | 14 ++- pkg/proxy/proxy.go | 251 +++++++++++++++++++++++++------------------ 4 files changed, 227 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index be603d5..06efdc1 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ hookdeck login --interactive Start a session to forward your events to an HTTP server. ```sh -hookdeck listen [--path?] +hookdeck listen [--path?] [--output?] ``` Hookdeck works by routing events received for a given `source` (i.e., Shopify, Github, etc.) to its defined `destination` by connecting them with a `connection` to a `destination`. The CLI allows you to receive events for any given connection and forward them to your localhost at the specified port or any valid URL. @@ -218,6 +218,56 @@ Orders Service forwarding to /events/shopify/orders ``` +#### Controlling output verbosity + +The `--output` flag controls how events are displayed. This is useful for reducing resource usage in high-throughput scenarios or when running in the background. + +**Available modes:** + +- `interactive` (default) - Full interactive UI with event history, navigation, and keyboard shortcuts +- `compact` - Simple one-line logs for all events without interactive features +- `quiet` - Only displays fatal connection errors (network failures, timeouts), not HTTP errors + +All modes display connection information at startup and a connection status message. + +**Examples:** + +```sh +# Default - full interactive UI with keyboard shortcuts +$ hookdeck listen 3000 shopify + +# Simple logging mode - prints all events as one-line logs +$ hookdeck listen 3000 shopify --output compact + +# Quiet mode - only shows fatal connection errors +$ hookdeck listen 3000 shopify --output quiet +``` + +**Compact mode output:** +``` +Listening on +shopify +└─ Forwards to → http://localhost:3000 + +Connected. Waiting for events... + +2025-10-08 15:56:53 [200] POST http://localhost:3000 (45ms) → https://... +2025-10-08 15:56:54 [422] POST http://localhost:3000 (12ms) → https://... +``` + +**Quiet mode output:** +``` +Listening on +shopify +└─ Forwards to → http://localhost:3000 + +Connected. Waiting for events... + +2025-10-08 15:56:53 [ERROR] Failed to POST: connection refused +``` + +> Note: In `quiet` mode, only fatal errors are shown (connection failures, network unreachable, timeouts). HTTP error responses (4xx, 5xx) are not displayed as they are valid HTTP responses. + #### Viewing and interacting with your events Event logs for your CLI can be found at [https://dashboard.hookdeck.com/cli/events](https://dashboard.hookdeck.com/cli/events?ref=github-hookdeck-cli). Events can be replayed or saved at any time. diff --git a/pkg/cmd/listen.go b/pkg/cmd/listen.go index 8e8b095..73c9263 100644 --- a/pkg/cmd/listen.go +++ b/pkg/cmd/listen.go @@ -28,9 +28,10 @@ import ( ) type listenCmd struct { - cmd *cobra.Command - noWSS bool - path string + cmd *cobra.Command + noWSS bool + path string + output string } // Map --cli-path to --path @@ -96,6 +97,8 @@ Destination CLI path will be "/". To set the CLI path, use the "--path" flag.`, lc.cmd.Flags().StringVar(&lc.path, "path", "", "Sets the path to which events are forwarded e.g., /webhooks or /api/stripe") + lc.cmd.Flags().StringVar(&lc.output, "output", "interactive", "Output mode: interactive (full UI), compact (simple logs), quiet (only fatal errors)") + // --cli-path is an alias for lc.cmd.Flags().SetNormalizeFunc(normalizeCliPathFlag) @@ -145,6 +148,16 @@ func (lc *listenCmd) runListenCmd(cmd *cobra.Command, args []string) error { connectionQuery = args[2] } + // Validate output flag + validOutputModes := map[string]bool{ + "interactive": true, + "compact": true, + "quiet": true, + } + if !validOutputModes[lc.output] { + return errors.New("invalid --output mode. Must be: interactive, compact, or quiet") + } + _, err_port := strconv.ParseInt(args[0], 10, 64) var url *url.URL if err_port != nil { @@ -162,7 +175,8 @@ func (lc *listenCmd) runListenCmd(cmd *cobra.Command, args []string) error { } return listen.Listen(url, sourceQuery, connectionQuery, listen.Flags{ - NoWSS: lc.noWSS, - Path: lc.path, + NoWSS: lc.noWSS, + Path: lc.path, + Output: lc.output, }, &Config) } diff --git a/pkg/listen/listen.go b/pkg/listen/listen.go index 6a2c055..f845345 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -32,8 +32,9 @@ import ( ) type Flags struct { - NoWSS bool - Path string + NoWSS bool + Path string + Output string } // listenCmd represents the listen command @@ -126,8 +127,12 @@ Specify a single destination to update the path. For example, pass a connection fmt.Println() printSourcesWithConnections(config, sources, connections, URL, guestURL) fmt.Println() - fmt.Printf("%s\n", ansi.Faint("Events")) - fmt.Println() + + // Only show "Events" header in interactive mode + if flags.Output == "" || flags.Output == "interactive" { + fmt.Printf("%s\n", ansi.Faint("Events")) + fmt.Println() + } p := proxy.New(&proxy.Config{ DeviceName: config.DeviceName, @@ -142,6 +147,7 @@ Specify a single destination to update the path. For example, pass a connection URL: URL, Log: log.StandardLogger(), Insecure: config.Insecure, + Output: flags.Output, }, connections) err = p.Run(context.Background()) diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 329a82a..63d93c9 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -46,12 +46,12 @@ type Config struct { DashboardBaseURL string ConsoleBaseURL string WSBaseURL string - // Indicates whether to print full JSON objects to stdout - PrintJSON bool - Log *log.Logger + Log *log.Logger // Force use of unencrypted ws:// protocol instead of wss:// NoWSS bool Insecure bool + // Output mode: interactive, compact, quiet + Output string } // A Proxy opens a websocket connection with Hookdeck, listens for incoming @@ -92,22 +92,37 @@ func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { // printEventAndUpdateStatus prints the event log and updates the status line in one operation func (p *Proxy) printEventAndUpdateStatus(eventID string, status int, success bool, eventTime time.Time, eventData *websocket.Attempt, eventLog string, responseStatus int, responseHeaders map[string][]string, responseBody string, responseDuration time.Duration) { - // Create event info with all data passed as parameters (no shared state) - eventInfo := EventInfo{ - ID: eventID, - Status: status, - Success: success, - Time: eventTime, - Data: eventData, - LogLine: eventLog, - ResponseStatus: responseStatus, - ResponseHeaders: responseHeaders, - ResponseBody: responseBody, - ResponseDuration: responseDuration, - } - - // Delegate rendering to UI (pass showingDetails flag to block rendering while less is open) - p.ui.PrintEventAndUpdateStatus(eventInfo, p.hasReceivedEvent, p.showingDetails) + switch p.cfg.Output { + case "interactive": + if p.ui != nil { + // Create event info with all data passed as parameters (no shared state) + eventInfo := EventInfo{ + ID: eventID, + Status: status, + Success: success, + Time: eventTime, + Data: eventData, + LogLine: eventLog, + ResponseStatus: responseStatus, + ResponseHeaders: responseHeaders, + ResponseBody: responseBody, + ResponseDuration: responseDuration, + } + // Delegate rendering to UI (pass showingDetails flag to block rendering while less is open) + p.ui.PrintEventAndUpdateStatus(eventInfo, p.hasReceivedEvent, p.showingDetails) + } + + case "compact": + // Print all events (success and HTTP errors) + fmt.Println(eventLog) + + case "quiet": + // Only print FATAL errors (no response received - responseStatus == 0 && !success) + // HTTP 4xx/5xx responses are NOT printed (they're valid HTTP responses) + if !success && responseStatus == 0 { + fmt.Println(eventLog) + } + } } // startWaitingAnimation starts an animation for the waiting indicator @@ -125,7 +140,7 @@ func (p *Proxy) startWaitingAnimation(ctx context.Context) { case <-p.stopWaitingAnimation: return case <-ticker.C: - if !p.hasReceivedEvent { + if !p.hasReceivedEvent && p.ui != nil { p.ui.UpdateStatusLine(p.hasReceivedEvent) } } @@ -141,6 +156,11 @@ func (p *Proxy) quit() { // toggleDetailsView shows event details (blocking until user exits less with 'q') func (p *Proxy) toggleDetailsView() { + // Only available in interactive mode + if p.keyboardHandler == nil || p.eventActions == nil || p.ui == nil { + return + } + // Set flag BEFORE calling ShowEventDetails, since it blocks until less exits p.showingDetails = true @@ -168,6 +188,11 @@ func (p *Proxy) toggleDetailsView() { // navigateEvents moves the selection up or down in the event history (within navigable events) func (p *Proxy) navigateEvents(direction int) { + // Only available in interactive mode + if p.eventHistory == nil || p.ui == nil { + return + } + // Delegate to EventHistory and redraw if selection changed if p.eventHistory.Navigate(direction) { p.ui.RedrawEventsWithSelection(p.hasReceivedEvent) @@ -201,11 +226,11 @@ func (p *Proxy) Run(parentCtx context.Context) error { }).Debug("Ctrl+C received, cleaning up...") }) - // Start keyboard listener for keyboard shortcuts - p.keyboardHandler.Start(signalCtx) - - // Start waiting animation - p.startWaitingAnimation(signalCtx) + // Start keyboard listener and waiting animation only for interactive mode + if p.cfg.Output == "interactive" && p.keyboardHandler != nil { + p.keyboardHandler.Start(signalCtx) + p.startWaitingAnimation(signalCtx) + } s := ansi.StartNewSpinner("Getting ready...", p.cfg.Log.Out) @@ -284,8 +309,15 @@ func (p *Proxy) Run(parentCtx context.Context) error { // Stop the spinner ansi.StopSpinner(s, "", p.cfg.Log.Out) - // Always update the status line to show current state - p.ui.UpdateStatusLine(p.hasReceivedEvent) + // Show connection status based on output mode + if p.ui != nil { + // Interactive mode: update status line + p.ui.UpdateStatusLine(p.hasReceivedEvent) + } else { + // Compact/quiet mode: print simple connection status + color := ansi.Color(os.Stdout) + fmt.Printf("%s\n\n", color.Faint("Connected. Waiting for events...")) + } hasConnectedOnce = true }() @@ -380,83 +412,85 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { "prefix": "proxy.Proxy.processAttempt", }).Debugf("Processing webhook event") - if p.cfg.PrintJSON { - p.ui.SafePrintf("%s\n", webhookEvent.Body.Request.DataString) - } else { - url := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: p.cfg.Insecure}, - } + url := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: p.cfg.Insecure}, + } - timeout := webhookEvent.Body.Request.Timeout - if timeout == 0 { - timeout = 1000 * 30 - } + timeout := webhookEvent.Body.Request.Timeout + if timeout == 0 { + timeout = 1000 * 30 + } - client := &http.Client{ - Timeout: time.Duration(timeout) * time.Millisecond, - Transport: tr, - } + client := &http.Client{ + Timeout: time.Duration(timeout) * time.Millisecond, + Transport: tr, + } - req, err := http.NewRequest(webhookEvent.Body.Request.Method, url, nil) - if err != nil { + req, err := http.NewRequest(webhookEvent.Body.Request.Method, url, nil) + if err != nil { + // Handle error gracefully - only print in interactive mode + if p.ui != nil { p.ui.SafePrintf("Error: %s\n", err) - return } - x := make(map[string]json.RawMessage) - err = json.Unmarshal(webhookEvent.Body.Request.Headers, &x) - if err != nil { + return + } + x := make(map[string]json.RawMessage) + err = json.Unmarshal(webhookEvent.Body.Request.Headers, &x) + if err != nil { + // Handle error gracefully - only print in interactive mode + if p.ui != nil { p.ui.SafePrintf("Error: %s\n", err) - return } + return + } - for key, value := range x { - unquoted_value, _ := strconv.Unquote(string(value)) - req.Header.Set(key, unquoted_value) - } + for key, value := range x { + unquoted_value, _ := strconv.Unquote(string(value)) + req.Header.Set(key, unquoted_value) + } - req.Body = ioutil.NopCloser(strings.NewReader(webhookEvent.Body.Request.DataString)) - req.ContentLength = int64(len(webhookEvent.Body.Request.DataString)) + req.Body = ioutil.NopCloser(strings.NewReader(webhookEvent.Body.Request.DataString)) + req.ContentLength = int64(len(webhookEvent.Body.Request.DataString)) - // Track request start time for duration calculation - requestStartTime := time.Now() + // Track request start time for duration calculation + requestStartTime := time.Now() - res, err := client.Do(req) + res, err := client.Do(req) - if err != nil { - color := ansi.Color(os.Stdout) - localTime := time.Now().Format(timeLayout) + if err != nil { + color := ansi.Color(os.Stdout) + localTime := time.Now().Format(timeLayout) - errStr := fmt.Sprintf("%s [%s] Failed to %s: %v", - color.Faint(localTime), - color.Red("ERROR").Bold(), - webhookEvent.Body.Request.Method, - err, - ) + errStr := fmt.Sprintf("%s [%s] Failed to %s: %v", + color.Faint(localTime), + color.Red("ERROR").Bold(), + webhookEvent.Body.Request.Method, + err, + ) - // Mark as having received first event - if !p.hasReceivedEvent { - p.hasReceivedEvent = true - // Stop the waiting animation - if p.stopWaitingAnimation != nil { - p.stopWaitingAnimation <- true - } + // Mark as having received first event + if !p.hasReceivedEvent { + p.hasReceivedEvent = true + // Stop the waiting animation + if p.stopWaitingAnimation != nil { + p.stopWaitingAnimation <- true } - - // Print the error and update status line with event-specific data (no response data for errors) - p.printEventAndUpdateStatus(eventID, 0, false, time.Now(), webhookEvent, errStr, 0, nil, "", 0) - - p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ - ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ - Event: "attempt_response", - Body: websocket.ErrorAttemptBody{ - AttemptId: webhookEvent.Body.AttemptId, - Error: true, - }, - }}) - } else { - p.processEndpointResponse(webhookEvent, res, requestStartTime) } + + // Print the error and update status line with event-specific data (no response data for errors) + p.printEventAndUpdateStatus(eventID, 0, false, time.Now(), webhookEvent, errStr, 0, nil, "", 0) + + p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ + ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ + Event: "attempt_response", + Body: websocket.ErrorAttemptBody{ + AttemptId: webhookEvent.Body.AttemptId, + Error: true, + }, + }}) + } else { + p.processEndpointResponse(webhookEvent, res, requestStartTime) } } @@ -545,29 +579,38 @@ func New(cfg *Config, connections []*hookdecksdk.Connection) *Proxy { cfg.Log = &log.Logger{Out: ioutil.Discard} } - eventHistory := NewEventHistory() - ui := NewTerminalUI(eventHistory) + // Default to interactive mode if not specified + if cfg.Output == "" { + cfg.Output = "interactive" + } p := &Proxy{ cfg: cfg, connections: connections, connectionTimer: time.NewTimer(0), // Defaults to no delay - eventHistory: eventHistory, - ui: ui, } - // Create event actions handler - p.eventActions = NewEventActions(cfg, eventHistory, ui) - - // Create keyboard handler and set up callbacks - p.keyboardHandler = NewKeyboardHandler(ui, &p.hasReceivedEvent, &p.isConnected, &p.showingDetails) - p.keyboardHandler.SetCallbacks( - p.navigateEvents, - p.eventActions.RetrySelectedEvent, - p.eventActions.OpenSelectedEventURL, - p.toggleDetailsView, - p.quit, - ) + // Only create interactive components for interactive mode + if cfg.Output == "interactive" { + eventHistory := NewEventHistory() + ui := NewTerminalUI(eventHistory) + + p.eventHistory = eventHistory + p.ui = ui + + // Create event actions handler + p.eventActions = NewEventActions(cfg, eventHistory, ui) + + // Create keyboard handler and set up callbacks + p.keyboardHandler = NewKeyboardHandler(ui, &p.hasReceivedEvent, &p.isConnected, &p.showingDetails) + p.keyboardHandler.SetCallbacks( + p.navigateEvents, + p.eventActions.RetrySelectedEvent, + p.eventActions.OpenSelectedEventURL, + p.toggleDetailsView, + p.quit, + ) + } return p } From bcfc3afd89cc0411c1a582559c8033fd7a504cc7 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Thu, 9 Oct 2025 09:36:15 -0400 Subject: [PATCH 19/22] chore: Cleanup history and handle resize --- pkg/proxy/event_history.go | 67 ++++++++++++++++++------ pkg/proxy/keyboard.go | 24 ++++++++- pkg/proxy/proxy.go | 19 +++++-- pkg/proxy/terminal_ui.go | 104 ++++++++++++++++++++++++++++--------- 4 files changed, 168 insertions(+), 46 deletions(-) diff --git a/pkg/proxy/event_history.go b/pkg/proxy/event_history.go index 1ac4a10..89c2ef8 100644 --- a/pkg/proxy/event_history.go +++ b/pkg/proxy/event_history.go @@ -56,24 +56,42 @@ func (eh *EventHistory) AddEvent(eventInfo EventInfo) bool { // Add to history (either new event or retry with different timestamp) eh.events = append(eh.events, eventInfo) - // Limit history to last 50 events - trim old ones + // Limit history: keep only navigable events + selected event if outside range if len(eh.events) > maxHistorySize { - // Remove oldest event - removedCount := len(eh.events) - maxHistorySize - eh.events = eh.events[removedCount:] - - // Adjust selected index if it was pointing to a removed event - if eh.selectedIndex < removedCount { - eh.selectedIndex = 0 - eh.userNavigated = false // Reset navigation since selected event was removed + // Determine which events to keep + historySize := len(eh.events) + navigableStartIdx := historySize - maxNavigableEvents + + var eventsToKeep []EventInfo + var newSelectedIndex int + + // If user has navigated to an event outside the navigable range, keep it + if eh.userNavigated && eh.selectedIndex < navigableStartIdx { + // Keep the selected event + all navigable events + eventsToKeep = make([]EventInfo, 0, maxNavigableEvents+1) + eventsToKeep = append(eventsToKeep, eh.events[eh.selectedIndex]) + eventsToKeep = append(eventsToKeep, eh.events[navigableStartIdx:]...) + newSelectedIndex = 0 // Selected event is now at index 0 } else { - eh.selectedIndex -= removedCount + // Just keep the navigable events + eventsToKeep = eh.events[navigableStartIdx:] + // Adjust selected index relative to new array + if eh.selectedIndex >= navigableStartIdx { + newSelectedIndex = eh.selectedIndex - navigableStartIdx + } else { + // Selected event was outside range and user hasn't navigated, select latest + newSelectedIndex = len(eventsToKeep) - 1 + eh.userNavigated = false + } } - } - // Auto-select the latest event unless user has navigated away - if !eh.userNavigated { - eh.selectedIndex = len(eh.events) - 1 + eh.events = eventsToKeep + eh.selectedIndex = newSelectedIndex + } else { + // Auto-select the latest event unless user has navigated away + if !eh.userNavigated { + eh.selectedIndex = len(eh.events) - 1 + } } return true @@ -100,12 +118,21 @@ func (eh *EventHistory) GetSelectedIndex() int { // GetSelectedEvent returns a copy of the currently selected event, or nil if no event is selected // Returns a copy to avoid issues with slice reallocation and concurrent modifications func (eh *EventHistory) GetSelectedEvent() *EventInfo { - eh.mu.RLock() - defer eh.mu.RUnlock() + eh.mu.Lock() + defer eh.mu.Unlock() - if eh.selectedIndex < 0 || eh.selectedIndex >= len(eh.events) { + // Bounds checking with automatic correction + if len(eh.events) == 0 { + eh.selectedIndex = -1 return nil } + + // Fix out-of-bounds index by resetting to latest event + if eh.selectedIndex < 0 || eh.selectedIndex >= len(eh.events) { + eh.selectedIndex = len(eh.events) - 1 + eh.userNavigated = false // Reset navigation state since we're forcing a valid selection + } + // Return a copy of the event to avoid pointer issues when slice is modified eventCopy := eh.events[eh.selectedIndex] return &eventCopy @@ -193,6 +220,12 @@ func (eh *EventHistory) Navigate(direction int) bool { return false } + // Ensure selectedIndex is valid before proceeding + if eh.selectedIndex < 0 || eh.selectedIndex >= len(eh.events) { + eh.selectedIndex = len(eh.events) - 1 + eh.userNavigated = false + } + // Calculate navigable indices (inline to avoid double-locking) historySize := len(eh.events) normalStartIdx := historySize - maxNavigableEvents diff --git a/pkg/proxy/keyboard.go b/pkg/proxy/keyboard.go index 41b07c4..6bc9282 100644 --- a/pkg/proxy/keyboard.go +++ b/pkg/proxy/keyboard.go @@ -3,7 +3,9 @@ package proxy import ( "context" "os" + "os/signal" "sync" + "syscall" log "github.com/sirupsen/logrus" "golang.org/x/term" @@ -90,13 +92,33 @@ func (kh *KeyboardHandler) DrainBufferedInput() { } } -// Start begins listening for keyboard input +// Start begins listening for keyboard input and terminal resize signals func (kh *KeyboardHandler) Start(ctx context.Context) { // Check if we're in a terminal if !term.IsTerminal(int(os.Stdin.Fd())) { return } + // Set up terminal resize signal handler + sigwinchCh := make(chan os.Signal, 1) + signal.Notify(sigwinchCh, syscall.SIGWINCH) + + // Start goroutine to handle terminal resize signals + go func() { + for { + select { + case <-ctx.Done(): + signal.Stop(sigwinchCh) + close(sigwinchCh) + return + case <-sigwinchCh: + // Terminal was resized - trigger a redraw with new dimensions + log.WithField("prefix", "KeyboardHandler.Start").Debug("Terminal resize detected") + kh.ui.HandleResize(*kh.hasReceivedEvent) + } + } + }() + go func() { // Enter raw mode once and keep it oldState, err := term.MakeRaw(int(os.Stdin.Fd())) diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 63d93c9..b052a33 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -26,8 +26,8 @@ import ( ) const timeLayout = "2006-01-02 15:04:05" -const maxHistorySize = 50 // Maximum events to keep in memory const maxNavigableEvents = 10 // Only last 10 events are navigable +const maxHistorySize = maxNavigableEvents + 1 // Keep navigable events + 1 for selected event outside range // // Public types @@ -306,12 +306,13 @@ func (p *Proxy) Run(parentCtx context.Context) error { p.isConnected = true nAttempts = 0 - // Stop the spinner + // Stop the spinner and clear its line ansi.StopSpinner(s, "", p.cfg.Log.Out) // Show connection status based on output mode if p.ui != nil { - // Interactive mode: update status line + // Interactive mode: clear the spinner line (blank line already exists from listen.go) + fmt.Print("\033[2K\r") // Clear current line (where spinner was) p.ui.UpdateStatusLine(p.hasReceivedEvent) } else { // Compact/quiet mode: print simple connection status @@ -338,7 +339,17 @@ func (p *Proxy) Run(parentCtx context.Context) error { if !canConnect() { // Stop the spinner before fatal error (terminal will be restored by defer) ansi.StopSpinner(s, "", p.cfg.Log.Out) - fmt.Print("\033[2K\r") + + // In interactive mode, need to clear the status line and move to a new line + if p.ui != nil { + // Temporarily restore terminal to print the error properly + p.ui.TemporarilyRestoreNormalMode() + // Move cursor up to status line and clear it, then move down + fmt.Print("\033[1A\033[2K\r\n") + } else { + // Non-interactive mode: just clear the spinner line + fmt.Print("\033[2K\r") + } // Print error without timestamp (use fmt instead of log to avoid formatter) color := ansi.Color(os.Stdout) diff --git a/pkg/proxy/terminal_ui.go b/pkg/proxy/terminal_ui.go index 4a460a7..9a58d0f 100644 --- a/pkg/proxy/terminal_ui.go +++ b/pkg/proxy/terminal_ui.go @@ -16,13 +16,46 @@ type TerminalUI struct { statusLineShown bool waitingAnimationFrame int eventHistory *EventHistory + terminalWidth int // Cached terminal width, updated on resize } // NewTerminalUI creates a new TerminalUI instance func NewTerminalUI(eventHistory *EventHistory) *TerminalUI { - return &TerminalUI{ + ui := &TerminalUI{ eventHistory: eventHistory, } + ui.updateTerminalWidth() + return ui +} + +// updateTerminalWidth updates the cached terminal width +func (ui *TerminalUI) updateTerminalWidth() { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil || width <= 0 { + width = 80 // Default fallback + } + // Enforce minimum width for usability + if width < 40 { + width = 40 + } + ui.terminalWidth = width +} + +// GetTerminalWidth returns the cached terminal width +func (ui *TerminalUI) GetTerminalWidth() int { + return ui.terminalWidth +} + +// HandleResize updates terminal width and triggers a redraw +func (ui *TerminalUI) HandleResize(hasReceivedEvent bool) { + ui.terminalMutex.Lock() + ui.updateTerminalWidth() + ui.terminalMutex.Unlock() + + // Trigger a full redraw with the new terminal width + if hasReceivedEvent && ui.eventHistory.Count() > 0 { + ui.RedrawEventsWithSelection(hasReceivedEvent) + } } // SetRawModeState stores the terminal's raw mode state for safe printing @@ -66,9 +99,9 @@ func (ui *TerminalUI) SafePrintf(format string, args ...interface{}) { // calculateEventLines calculates how many terminal lines an event log occupies // accounting for line wrapping based on terminal width func (ui *TerminalUI) calculateEventLines(logLine string) int { - // Get terminal width - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { + // Use cached terminal width + width := ui.terminalWidth + if width <= 0 { width = 80 // Default fallback } @@ -87,6 +120,7 @@ func (ui *TerminalUI) calculateEventLines(logLine string) int { } // BuildStatusMessage generates the status line message based on the current state +// Adjusts verbosity based on terminal width func (ui *TerminalUI) BuildStatusMessage(hasReceivedEvent bool) string { color := ansi.Color(os.Stdout) @@ -108,35 +142,57 @@ func (ui *TerminalUI) BuildStatusMessage(hasReceivedEvent bool) string { return "" // No events available } - // If user has navigated, show "Selected event" + // Determine verbosity based on terminal width + width := ui.terminalWidth + isNarrow := width < 100 // Narrow terminals get compact messages + isVeryNarrow := width < 60 // Very narrow terminals get minimal messages + + // Build status message based on terminal width + var statusMsg string + eventType := "Last event" if ui.eventHistory.IsUserNavigated() { - if selectedEvent.Success { - return fmt.Sprintf("> %s Selected event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", - color.Green("āœ“"), selectedEvent.Status) - } else { - if selectedEvent.Status == 0 { - return fmt.Sprintf("> %s Selected event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data & • [Ctrl+C] Quit", - color.Red("x").Bold()) - } else { - return fmt.Sprintf("> %s Selected event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", - color.Red("x").Bold(), selectedEvent.Status) - } - } + eventType = "Selected event" } - // Auto-selecting latest event - show "Last event" if selectedEvent.Success { - return fmt.Sprintf("> %s Last event succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", - color.Green("āœ“"), selectedEvent.Status) + if isVeryNarrow { + statusMsg = fmt.Sprintf("> %s %s [%d]", color.Green("āœ“"), eventType, selectedEvent.Status) + } else if isNarrow { + statusMsg = fmt.Sprintf("> %s %s succeeded [%d] | [↑↓] [r] [o] [d] [q]", + color.Green("āœ“"), eventType, selectedEvent.Status) + } else { + statusMsg = fmt.Sprintf("> %s %s succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", + color.Green("āœ“"), eventType, selectedEvent.Status) + } } else { + statusText := "failed" if selectedEvent.Status == 0 { - return fmt.Sprintf("> %s Last event failed with error | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit", - color.Red("x").Bold()) + statusText = "failed with error" } else { - return fmt.Sprintf("> %s Last event failed with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show request details • [Ctrl+C] Quit", - color.Red("x").Bold(), selectedEvent.Status) + statusText = fmt.Sprintf("failed with status %d", selectedEvent.Status) + } + + if isVeryNarrow { + if selectedEvent.Status == 0 { + statusMsg = fmt.Sprintf("> %s %s [ERR]", color.Red("x").Bold(), eventType) + } else { + statusMsg = fmt.Sprintf("> %s %s [%d]", color.Red("x").Bold(), eventType, selectedEvent.Status) + } + } else if isNarrow { + if selectedEvent.Status == 0 { + statusMsg = fmt.Sprintf("> %s %s failed | [↑↓] [r] [o] [d] [q]", + color.Red("x").Bold(), eventType) + } else { + statusMsg = fmt.Sprintf("> %s %s failed [%d] | [↑↓] [r] [o] [d] [q]", + color.Red("x").Bold(), eventType, selectedEvent.Status) + } + } else { + statusMsg = fmt.Sprintf("> %s %s %s | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", + color.Red("x").Bold(), eventType, statusText) } } + + return statusMsg } // UpdateStatusLine updates the bottom status line with the latest event information From cae88db3bc277f4a1c1c92eb945f629c19d1fa98 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Sun, 12 Oct 2025 13:33:55 -0400 Subject: [PATCH 20/22] feat: Refactor to use TUI --- README.md | 230 +++++++----- go.mod | 25 +- go.sum | 41 ++ pkg/listen/listen.go | 20 +- pkg/proxy/event_actions.go | 349 ----------------- pkg/proxy/event_history.go | 297 --------------- pkg/proxy/keyboard.go | 253 ------------- pkg/proxy/proxy.go | 583 +---------------------------- pkg/proxy/proxy_tui.go | 741 +++++++++++++++++++++++++++++++++++++ pkg/proxy/terminal_ui.go | 614 ------------------------------ pkg/tui/model.go | 401 ++++++++++++++++++++ pkg/tui/styles.go | 84 +++++ pkg/tui/update.go | 262 +++++++++++++ pkg/tui/view.go | 443 ++++++++++++++++++++++ 14 files changed, 2149 insertions(+), 2194 deletions(-) delete mode 100644 pkg/proxy/event_actions.go delete mode 100644 pkg/proxy/event_history.go delete mode 100644 pkg/proxy/keyboard.go create mode 100644 pkg/proxy/proxy_tui.go delete mode 100644 pkg/proxy/terminal_ui.go create mode 100644 pkg/tui/model.go create mode 100644 pkg/tui/styles.go create mode 100644 pkg/tui/update.go create mode 100644 pkg/tui/view.go diff --git a/README.md b/README.md index 06efdc1..26d80a2 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ hookdeck login ``` If you are in an environment without a browser (e.g., a TTY-only terminal), you can use the `--interactive` (or `-i`) flag to log in by pasting your API key: + ```sh hookdeck login --interactive ``` @@ -117,22 +118,35 @@ Hookdeck works by routing events received for a given `source` (i.e., Shopify, G Each `source` is assigned an Event URL, which you can use to receive events. When starting with a fresh account, the CLI will prompt you to create your first source. Each CLI process can listen to one source at a time. +> The `port-or-URL` param is mandatory, events will be forwarded to http://localhost:$PORT/$DESTINATION_PATH when inputing a valid port or your provided URL. + +#### Interactive Mode + +The default interactive mode uses a full-screen TUI (Terminal User Interface) with an alternative screen buffer, meaning your terminal history is preserved when you exit. The interface includes: + +- **Connection Header**: Shows your sources, webhook URLs, and connection routing + - Auto-collapses when the first event arrives to save space + - Toggle with `i` to expand/collapse connection details +- **Event List**: Scrollable history of all received events (up to 1000 events) + - Auto-scrolls to show latest events as they arrive + - Manual navigation pauses auto-scrolling +- **Status Bar**: Shows event details and available keyboard shortcuts +- **Event Details View**: Full request/response inspection with headers and body + #### Interactive Keyboard Shortcuts -While the listen command is running, you can use the following keyboard shortcuts: +While in interactive mode, you can use the following keyboard shortcuts: -- `↑` / `↓` - Navigate between events (select different events) +- `↑` / `↓` or `k` / `j` - Navigate between events (select different events) +- `i` - Toggle connection information (expand/collapse connection details) - `r` - Retry the selected event - `o` - Open the selected event in the Hookdeck dashboard -- `d` - Show detailed request information for the selected event (headers, body, etc.) -- `q` - Quit the application +- `d` - Show detailed request/response information for the selected event (press `d` or `ESC` to close) + - When details view is open: `↑` / `↓` scroll through content, `PgUp` / `PgDown` for page navigation +- `q` - Quit the application (terminal state is restored) - `Ctrl+C` - Also quits the application -The selected event is indicated by a `>` character at the beginning of the line. All actions (retry, open, details) work on the currently selected event, not just the latest one. These shortcuts are displayed in the status line at the bottom of the terminal. - -Contrary to ngrok, **Hookdeck does not allow to append a path to your event URL**. Instead, the routing is done within Hookdeck configuration. This means you will also be prompted to specify your `destination` path, and you can have as many as you want per `source`. - -> The `port-or-URL` param is mandatory, events will be forwarded to http://localhost:$PORT/$DESTINATION_PATH when inputing a valid port or your provided URL. +The selected event is indicated by a `>` character at the beginning of the line. All actions (retry, open, details) work on the currently selected event, not just the latest one. These shortcuts are displayed in the status bar at the bottom of the screen. #### Listen to all your connections for a given source @@ -141,18 +155,24 @@ The second param, `source-alias` is used to select a specific source to listen o ```sh $ hookdeck listen 3000 shopify -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +ā—ā”€ā”€ HOOKDECK CLI ā”€ā”€ā— + +Listening on 1 source • 2 connections • [i] Collapse Shopify Source -šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +ā”œā”€ Forwards to → http://localhost:3000/webhooks/shopify/inventory (Inventory Service) +└─ Forwards to → http://localhost:3000/webhooks/shopify/orders (Orders Service) -Connections -Inventory Service forwarding to /webhooks/shopify/inventory -Orders Service forwarding to /webhooks/shopify/orders +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli?team_id=... +Events • [↑↓] Navigate ────────────────────────────────────────────────────────── -⣾ Getting ready... +2025-10-12 14:32:15 [200] POST http://localhost:3000/webhooks/shopify/orders (23ms) → https://dashboard.hookdeck.com/events/evt_... +> 2025-10-12 14:32:18 [200] POST http://localhost:3000/webhooks/shopify/inventory (45ms) → https://dashboard.hookdeck.com/events/evt_... +─────────────────────────────────────────────────────────────────────────────── +> āœ“ Last event succeeded with status 200 | [r] Retry • [o] Open in dashboard • [d] Show data ``` #### Listen to multiple sources @@ -162,20 +182,32 @@ Orders Service forwarding to /webhooks/shopify/orders ```sh $ hookdeck listen 3000 '*' -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +ā—ā”€ā”€ HOOKDECK CLI ā”€ā”€ā— + +Listening on 3 sources • 3 connections • [i] Collapse -Sources -šŸ”Œ stripe URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn01 -šŸ”Œ shopify URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn02 -šŸ”Œ twilio URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn03 +stripe +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn01 +└─ Forwards to → http://localhost:3000/webhooks/stripe (cli-stripe) -Connections -stripe -> cli-stripe forwarding to /webhooks/stripe -shopify -> cli-shopify forwarding to /webhooks/shopify -twilio -> cli-twilio forwarding to /webhooks/twilio +shopify +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn02 +└─ Forwards to → http://localhost:3000/webhooks/shopify (cli-shopify) + +twilio +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHn03 +└─ Forwards to → http://localhost:3000/webhooks/twilio (cli-twilio) + +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli?team_id=... -⣾ Getting ready... +Events • [↑↓] Navigate ────────────────────────────────────────────────────────── +2025-10-12 14:35:21 [200] POST http://localhost:3000/webhooks/stripe (12ms) → https://dashboard.hookdeck.com/events/evt_... +2025-10-12 14:35:44 [200] POST http://localhost:3000/webhooks/shopify (31ms) → https://dashboard.hookdeck.com/events/evt_... +> 2025-10-12 14:35:52 [200] POST http://localhost:3000/webhooks/twilio (18ms) → https://dashboard.hookdeck.com/events/evt_... + +─────────────────────────────────────────────────────────────────────────────── +> āœ“ Last event succeeded with status 200 | [r] Retry • [o] Open in dashboard • [d] Show data ``` #### Listen to a subset of connections @@ -185,17 +217,22 @@ The 3rd param, `connection-query` can be used to filter the list of connections ```sh $ hookdeck listen 3000 shopify orders -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +ā—ā”€ā”€ HOOKDECK CLI ā”€ā”€ā— + +Listening on 1 source • 1 connection • [i] Collapse Shopify Source -šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +└─ Forwards to → http://localhost:3000/webhooks/shopify/orders (Orders Service) -Connections -Orders Service forwarding to /webhooks/shopify/orders +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli?team_id=... +Events • [↑↓] Navigate ────────────────────────────────────────────────────────── -⣾ Getting ready... +> 2025-10-12 14:38:09 [200] POST http://localhost:3000/webhooks/shopify/orders (27ms) → https://dashboard.hookdeck.com/events/evt_... +─────────────────────────────────────────────────────────────────────────────── +> āœ“ Last event succeeded with status 200 | [r] Retry • [o] Open in dashboard • [d] Show data ``` #### Changing the path events are forwarded to @@ -205,17 +242,22 @@ The `--path` flag sets the path to which events are forwarded. ```sh $ hookdeck listen 3000 shopify orders --path /events/shopify/orders -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +ā—ā”€ā”€ HOOKDECK CLI ā”€ā”€ā— + +Listening on 1 source • 1 connection • [i] Collapse Shopify Source -šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +└─ Forwards to → http://localhost:3000/events/shopify/orders (Orders Service) -Connections -Orders Service forwarding to /events/shopify/orders +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli?team_id=... +Events • [↑↓] Navigate ────────────────────────────────────────────────────────── -⣾ Getting ready... +> 2025-10-12 14:40:23 [200] POST http://localhost:3000/events/shopify/orders (19ms) → https://dashboard.hookdeck.com/events/evt_... +─────────────────────────────────────────────────────────────────────────────── +> āœ“ Last event succeeded with status 200 | [r] Retry • [o] Open in dashboard • [d] Show data ``` #### Controlling output verbosity @@ -224,8 +266,8 @@ The `--output` flag controls how events are displayed. This is useful for reduci **Available modes:** -- `interactive` (default) - Full interactive UI with event history, navigation, and keyboard shortcuts -- `compact` - Simple one-line logs for all events without interactive features +- `interactive` (default) - Full-screen TUI with alternative screen buffer, event history, navigation, and keyboard shortcuts. Your terminal history is preserved and restored when you exit. +- `compact` - Simple one-line logs for all events without interactive features. Events are appended to your terminal history. - `quiet` - Only displays fatal connection errors (network failures, timeouts), not HTTP errors All modes display connection information at startup and a connection status message. @@ -244,6 +286,7 @@ $ hookdeck listen 3000 shopify --output quiet ``` **Compact mode output:** + ``` Listening on shopify @@ -256,6 +299,7 @@ Connected. Waiting for events... ``` **Quiet mode output:** + ``` Listening on shopify @@ -289,6 +333,7 @@ For local development scenarios, you can instruct the `listen` command to bypass **This is dangerous and should only be used in trusted local development environments for destinations you control.** Example of skipping SSL validation for an HTTPS destination: + ```sh hookdeck listen --insecure https:/// ``` @@ -319,17 +364,22 @@ Done! The Hookdeck CLI is configured in project MyProject $ hookdeck listen 3000 shopify orders -šŸ‘‰ Inspect and replay events: https://dashboard.hookdeck.com/cli/events +ā—ā”€ā”€ HOOKDECK CLI ā”€ā”€ā— + +Listening on 1 source • 1 connection • [i] Collapse Shopify Source -šŸ”Œ Event URL: https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +│ Requests to → https://events.hookdeck.com/e/src_DAjaFWyyZXsFdZrTOKpuHnOH +└─ Forwards to → http://localhost:3000/webhooks/shopify/orders (Orders Service) -Connections -Inventory Service forwarding to /webhooks/shopify/inventory +šŸ’” View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli?team_id=... +Events • [↑↓] Navigate ────────────────────────────────────────────────────────── -⣾ Getting ready... +> 2025-10-12 14:42:55 [200] POST http://localhost:3000/webhooks/shopify/orders (34ms) → https://dashboard.hookdeck.com/events/evt_... +─────────────────────────────────────────────────────────────────────────────── +> āœ“ Last event succeeded with status 200 | [r] Retry • [o] Open in dashboard • [d] Show data ``` ### Manage active project @@ -359,38 +409,41 @@ hookdeck project use [ []] **Behavior:** -- **`hookdeck project use`** (no arguments): - An interactive prompt will guide you through selecting your organization and then the project within that organization. - ```sh - $ hookdeck project use - Use the arrow keys to navigate: ↓ ↑ → ← - ? Select Organization: - My Org - ā–ø Another Org - ... - ? Select Project (Another Org): - Project X - ā–ø Project Y - Selecting project Project Y - Successfully set active project to: [Another Org] Project Y - ``` - -- **`hookdeck project use `** (one argument): - Filters projects by the specified ``. - - If multiple projects exist under that organization, you'll be prompted to choose one. - - If only one project exists, it will be selected automatically. - ```sh - $ hookdeck project use "My Org" - # (If multiple projects, prompts to select. If one, auto-selects) - Successfully set active project to: [My Org] Default Project - ``` - -- **`hookdeck project use `** (two arguments): - Directly selects the project `` under the organization ``. - ```sh - $ hookdeck project use "My Corp" "API Staging" - Successfully set active project to: [My Corp] API Staging - ``` +- **`hookdeck project use`** (no arguments): + An interactive prompt will guide you through selecting your organization and then the project within that organization. + + ```sh + $ hookdeck project use + Use the arrow keys to navigate: ↓ ↑ → ← + ? Select Organization: + My Org + ā–ø Another Org + ... + ? Select Project (Another Org): + Project X + ā–ø Project Y + Selecting project Project Y + Successfully set active project to: [Another Org] Project Y + ``` + +- **`hookdeck project use `** (one argument): + Filters projects by the specified ``. + + - If multiple projects exist under that organization, you'll be prompted to choose one. + - If only one project exists, it will be selected automatically. + + ```sh + $ hookdeck project use "My Org" + # (If multiple projects, prompts to select. If one, auto-selects) + Successfully set active project to: [My Org] Default Project + ``` + +- **`hookdeck project use `** (two arguments): + Directly selects the project `` under the organization ``. + ```sh + $ hookdeck project use "My Corp" "API Staging" + Successfully set active project to: [My Corp] API Staging + ``` Upon successful selection, you will generally see a confirmation message like: `Successfully set active project to: [] ` @@ -403,9 +456,9 @@ The Hookdeck CLI uses configuration files to store the your keys, project settin The CLI will look for the configuration file in the following order: - 1. The `--config` flag, which allows you to specify a custom configuration file name and path per command. - 2. The local directory `.hookdeck/config.toml`. - 3. The default global configuration file location. +1. The `--config` flag, which allows you to specify a custom configuration file name and path per command. +2. The local directory `.hookdeck/config.toml`. +3. The default global configuration file location. ### Default configuration Location @@ -478,21 +531,20 @@ hookdeck listen 3030 webhooks -p prod The following flags can be used with any command: -* `--api-key`: Your API key to use for the command. -* `--color`: Turn on/off color output (on, off, auto). -* `--config`: Path to a specific configuration file. -* `--device-name`: A unique name for your device. -* `--insecure`: Allow invalid TLS certificates. -* `--log-level`: Set the logging level (debug, info, warn, error). -* `--profile` or `-p`: Use a specific configuration profile. +- `--api-key`: Your API key to use for the command. +- `--color`: Turn on/off color output (on, off, auto). +- `--config`: Path to a specific configuration file. +- `--device-name`: A unique name for your device. +- `--insecure`: Allow invalid TLS certificates. +- `--log-level`: Set the logging level (debug, info, warn, error). +- `--profile` or `-p`: Use a specific configuration profile. There are also some hidden flags that are mainly used for development and debugging: -* `--api-base`: Sets the API base URL. -* `--dashboard-base`: Sets the web dashboard base URL. -* `--console-base`: Sets the web console base URL. -* `--ws-base`: Sets the Websocket base URL. - +- `--api-base`: Sets the API base URL. +- `--dashboard-base`: Sets the web dashboard base URL. +- `--console-base`: Sets the web console base URL. +- `--ws-base`: Sets the Websocket base URL. ## Developing diff --git a/go.mod b/go.mod index 1fa1cb6..8180194 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/hookdeck/hookdeck-cli -go 1.18 +go 1.24.0 + +toolchain go1.24.8 require ( github.com/AlecAivazis/survey/v2 v2.3.7 @@ -19,12 +21,21 @@ require ( github.com/stretchr/testify v1.11.0 github.com/tidwall/pretty v1.2.1 github.com/x-cray/logrus-prefixed-formatter v0.5.2 - golang.org/x/sys v0.28.0 + golang.org/x/sys v0.36.0 golang.org/x/term v0.27.0 ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v0.21.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.9.0 // indirect github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/google/go-querystring v1.0.0 // indirect @@ -33,19 +44,27 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kr/text v0.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.3 // indirect github.com/mattn/go-colorable v0.1.7 // indirect - github.com/mattn/go-isatty v0.0.12 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mitchellh/mapstructure v1.3.3 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/onsi/ginkgo v1.14.1 // indirect github.com/onsi/gomega v1.10.1 // indirect github.com/pelletier/go-toml v1.8.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/afero v1.4.0 // indirect github.com/spf13/cast v1.3.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/subosito/gotenv v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect golang.org/x/text v0.4.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/go.sum b/go.sum index f2d45d3..b8584e4 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= @@ -32,6 +34,20 @@ github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJm github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= @@ -47,6 +63,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= @@ -153,6 +171,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.3 h1:kJSsc6EXkBLgr3SphHk9w5mtjn0bjlR4JYEXKrJ45rQ= github.com/magiconair/properties v1.8.3/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= @@ -166,6 +186,12 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= @@ -184,6 +210,12 @@ github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8 github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -214,6 +246,9 @@ github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -262,6 +297,8 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1 github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= @@ -349,11 +386,15 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= diff --git a/pkg/listen/listen.go b/pkg/listen/listen.go index f845345..d9ffeb9 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -123,18 +123,19 @@ Specify a single destination to update the path. For example, pass a connection } // Start proxy - printListenMessage(config, isMultiSource) - fmt.Println() - printSourcesWithConnections(config, sources, connections, URL, guestURL) - fmt.Println() - - // Only show "Events" header in interactive mode - if flags.Output == "" || flags.Output == "interactive" { + // For non-interactive modes, print connection info before starting + if flags.Output == "compact" || flags.Output == "quiet" { + printListenMessage(config, isMultiSource) + fmt.Println() + printSourcesWithConnections(config, sources, connections, URL, guestURL) + fmt.Println() fmt.Printf("%s\n", ansi.Faint("Events")) fmt.Println() } + // For interactive mode, connection info will be shown in TUI - p := proxy.New(&proxy.Config{ + // Use new TUI-based proxy + p := proxy.NewTUI(&proxy.Config{ DeviceName: config.DeviceName, Key: config.Profile.APIKey, ProjectID: config.Profile.ProjectId, @@ -148,7 +149,8 @@ Specify a single destination to update the path. For example, pass a connection Log: log.StandardLogger(), Insecure: config.Insecure, Output: flags.Output, - }, connections) + GuestURL: guestURL, + }, sources, connections) err = p.Run(context.Background()) if err != nil { diff --git a/pkg/proxy/event_actions.go b/pkg/proxy/event_actions.go deleted file mode 100644 index 888f9c1..0000000 --- a/pkg/proxy/event_actions.go +++ /dev/null @@ -1,349 +0,0 @@ -package proxy - -import ( - "context" - "encoding/json" - "fmt" - "net/url" - "os" - "os/exec" - "runtime" - "sort" - "strconv" - "strings" - - "github.com/hookdeck/hookdeck-cli/pkg/ansi" - "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" -) - -// EventActions handles actions on selected events (retry, open, view details) -type EventActions struct { - cfg *Config - eventHistory *EventHistory - ui *TerminalUI -} - -// NewEventActions creates a new EventActions instance -func NewEventActions(cfg *Config, eventHistory *EventHistory, ui *TerminalUI) *EventActions { - return &EventActions{ - cfg: cfg, - eventHistory: eventHistory, - ui: ui, - } -} - -// RetrySelectedEvent retries the currently selected event -func (ea *EventActions) RetrySelectedEvent() { - selectedEvent := ea.eventHistory.GetSelectedEvent() - if selectedEvent == nil { - color := ansi.Color(os.Stdout) - ea.ui.SafePrintf("[%s] No event selected to retry\n", - color.Yellow("WARN"), - ) - return - } - - eventID := selectedEvent.ID - if eventID == "" { - color := ansi.Color(os.Stdout) - ea.ui.SafePrintf("[%s] Selected event has no ID to retry\n", - color.Yellow("WARN"), - ) - return - } - - // Create HTTP client for retry request - parsedBaseURL, err := url.Parse(ea.cfg.APIBaseURL) - if err != nil { - color := ansi.Color(os.Stdout) - ea.ui.SafePrintf("[%s] Failed to parse API URL for retry: %v\n", - color.Red("ERROR").Bold(), - err, - ) - return - } - - client := &hookdeck.Client{ - BaseURL: parsedBaseURL, - APIKey: ea.cfg.Key, - ProjectID: ea.cfg.ProjectID, - } - - // Make retry request to Hookdeck API - retryURL := fmt.Sprintf("/events/%s/retry", eventID) - resp, err := client.Post(context.Background(), retryURL, []byte("{}"), nil) - if err != nil { - color := ansi.Color(os.Stdout) - ea.ui.SafePrintf("[%s] Failed to retry event %s: %v\n", - color.Red("ERROR").Bold(), - eventID, - err, - ) - return - } - defer resp.Body.Close() -} - -// OpenSelectedEventURL opens the currently selected event in the browser -func (ea *EventActions) OpenSelectedEventURL() { - selectedEvent := ea.eventHistory.GetSelectedEvent() - if selectedEvent == nil { - color := ansi.Color(os.Stdout) - ea.ui.SafePrintf("[%s] No event selected to open\n", - color.Yellow("WARN"), - ) - return - } - - eventID := selectedEvent.ID - if eventID == "" { - color := ansi.Color(os.Stdout) - ea.ui.SafePrintf("[%s] Selected event has no ID to open\n", - color.Yellow("WARN"), - ) - return - } - - // Build event URL based on project mode - var eventURL string - if ea.cfg.ProjectMode == "console" { - eventURL = ea.cfg.ConsoleBaseURL + "/?event_id=" + eventID - } else { - eventURL = ea.cfg.DashboardBaseURL + "/events/" + eventID - } - - // Open URL in browser - err := ea.openBrowser(eventURL) - if err != nil { - color := ansi.Color(os.Stdout) - ea.ui.SafePrintf("[%s] Failed to open browser: %v\n", - color.Red("ERROR").Bold(), - err, - ) - return - } -} - -// openBrowser opens a URL in the default browser -func (ea *EventActions) openBrowser(url string) error { - var cmd string - var args []string - - switch runtime.GOOS { - case "windows": - cmd = "cmd" - args = []string{"/c", "start", url} - case "darwin": - cmd = "open" - args = []string{url} - default: // "linux", "freebsd", "openbsd", "netbsd" - cmd = "xdg-open" - args = []string{url} - } - - return exec.Command(cmd, args...).Start() -} - -// ShowEventDetails displays detailed event information using less pager -func (ea *EventActions) ShowEventDetails() (bool, error) { - selectedEvent := ea.eventHistory.GetSelectedEvent() - if selectedEvent == nil || selectedEvent.Data == nil { - return false, nil - } - - // Build the details content - webhookEvent := selectedEvent.Data - color := ansi.Color(os.Stdout) - var content strings.Builder - - // Header with navigation hints - content.WriteString(ansi.Bold("Event Details")) - content.WriteString("\n") - content.WriteString(ansi.Faint("Press 'q' to return to events • Use arrow keys/Page Up/Down to scroll")) - content.WriteString("\n") - content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) - content.WriteString("\n\n") - - // Request section - content.WriteString(ansi.Bold("Request")) - content.WriteString("\n\n") - // Construct the full URL with query params - fullURL := ea.cfg.URL.Scheme + "://" + ea.cfg.URL.Host + ea.cfg.URL.Path + webhookEvent.Body.Path - content.WriteString(fmt.Sprintf("%s %s\n\n", color.Bold(webhookEvent.Body.Request.Method), fullURL)) - - // Headers section - if len(webhookEvent.Body.Request.Headers) > 0 { - content.WriteString(ansi.Bold("Headers")) - content.WriteString("\n\n") - - var headers map[string]json.RawMessage - if err := json.Unmarshal(webhookEvent.Body.Request.Headers, &headers); err == nil { - keys := make([]string, 0, len(headers)) - for key := range headers { - keys = append(keys, key) - } - sort.Strings(keys) - - for _, key := range keys { - unquoted, _ := strconv.Unquote(string(headers[key])) - content.WriteString(fmt.Sprintf("%s: %s\n", ansi.Faint(strings.ToLower(key)), unquoted)) - } - } - content.WriteString("\n") - } - - // Body section - if len(webhookEvent.Body.Request.DataString) > 0 { - content.WriteString(ansi.Bold("Body")) - content.WriteString("\n\n") - - var bodyData interface{} - if err := json.Unmarshal([]byte(webhookEvent.Body.Request.DataString), &bodyData); err == nil { - prettyJSON, err := json.MarshalIndent(bodyData, "", " ") - if err == nil { - content.WriteString(string(prettyJSON)) - content.WriteString("\n") - } - } else { - content.WriteString(webhookEvent.Body.Request.DataString) - content.WriteString("\n") - } - } - - // Response section - content.WriteString("\n") - content.WriteString(ansi.Faint("───────────────────────────────────────────────────────────────────────────────")) - content.WriteString("\n\n") - - // Check if this was an error (no response received) - if selectedEvent.ResponseStatus == 0 && selectedEvent.ResponseBody == "" { - // Request failed - no response received - content.WriteString(ansi.Bold("Response")) - content.WriteString("\n\n") - content.WriteString(color.Red("Request failed - no response received").String()) - content.WriteString("\n") - } else { - // Response header with status and duration - responseStatusText := fmt.Sprintf("%d", selectedEvent.ResponseStatus) - if selectedEvent.ResponseStatus >= 200 && selectedEvent.ResponseStatus < 300 { - responseStatusText = color.Green(responseStatusText).String() - } else if selectedEvent.ResponseStatus >= 400 { - responseStatusText = color.Red(responseStatusText).String() - } else if selectedEvent.ResponseStatus >= 300 { - responseStatusText = color.Yellow(responseStatusText).String() - } - - durationMs := selectedEvent.ResponseDuration.Milliseconds() - content.WriteString(fmt.Sprintf("%s • %s • %dms\n\n", - ansi.Bold("Response"), - responseStatusText, - durationMs, - )) - - // Response headers section - if len(selectedEvent.ResponseHeaders) > 0 { - content.WriteString(ansi.Bold("Headers")) - content.WriteString("\n\n") - - // Sort header keys for consistent display - keys := make([]string, 0, len(selectedEvent.ResponseHeaders)) - for key := range selectedEvent.ResponseHeaders { - keys = append(keys, key) - } - sort.Strings(keys) - - for _, key := range keys { - values := selectedEvent.ResponseHeaders[key] - // Join multiple values with comma - content.WriteString(fmt.Sprintf("%s: %s\n", - ansi.Faint(strings.ToLower(key)), - strings.Join(values, ", "), - )) - } - content.WriteString("\n") - } - - // Response body section - if len(selectedEvent.ResponseBody) > 0 { - content.WriteString(ansi.Bold("Body")) - content.WriteString("\n\n") - - var bodyData interface{} - if err := json.Unmarshal([]byte(selectedEvent.ResponseBody), &bodyData); err == nil { - prettyJSON, err := json.MarshalIndent(bodyData, "", " ") - if err == nil { - content.WriteString(string(prettyJSON)) - content.WriteString("\n") - } - } else { - content.WriteString(selectedEvent.ResponseBody) - content.WriteString("\n") - } - } else { - content.WriteString(ansi.Faint("(empty)")) - content.WriteString("\n\n") - } - } - - // Footer - content.WriteString("\n") - content.WriteString(ansi.Faint("Press 'q' to return to events • Use arrow keys/Page Up/Down to scroll")) - content.WriteString("\n") - - // Create a temporary file for the content - tmpfile, err := os.CreateTemp("", "hookdeck-event-*.txt") - if err != nil { - // Fallback: print directly - fmt.Print(content.String()) - return false, nil - } - defer os.Remove(tmpfile.Name()) - - // Write content to temp file - if _, err := tmpfile.Write([]byte(content.String())); err != nil { - tmpfile.Close() - fmt.Print(content.String()) - return false, nil - } - tmpfile.Close() - - // Use less with options: - // -R: Allow ANSI color codes - // -P: Custom prompt to hide filename (show blank or custom message) - cmd := exec.Command("less", "-R", "-P?eEND:.", tmpfile.Name()) - - // CRITICAL: Restore normal terminal mode BEFORE opening /dev/tty - // Our keyboard handler has put stdin in raw mode, and /dev/tty shares the same terminal - // We need to restore normal mode so less can properly initialize its terminal handling - ea.ui.TemporarilyRestoreNormalMode() - - // CRITICAL: Open /dev/tty directly so less doesn't use stdin (which our keyboard handler is reading from) - // This gives less exclusive terminal access and prevents our keyboard handler from seeing its input - tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) - if err != nil { - // Fallback: use stdin (but this means keyboard handler might see input) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - } else { - defer tty.Close() - // Give less exclusive access via /dev/tty - cmd.Stdin = tty - cmd.Stdout = tty - cmd.Stderr = tty - } - - // Run less and wait for it to exit (it takes over terminal control) - err = cmd.Run() - - // Re-enable raw mode after less exits - ea.ui.ReEnableRawMode() - - if err != nil { - // Fallback: print directly - fmt.Print(content.String()) - return false, nil - } - - return true, nil -} diff --git a/pkg/proxy/event_history.go b/pkg/proxy/event_history.go deleted file mode 100644 index 89c2ef8..0000000 --- a/pkg/proxy/event_history.go +++ /dev/null @@ -1,297 +0,0 @@ -package proxy - -import ( - "sync" - "time" - - "github.com/hookdeck/hookdeck-cli/pkg/websocket" -) - -// EventInfo represents a single event for navigation -type EventInfo struct { - ID string - Status int - Success bool - Time time.Time - Data *websocket.Attempt - LogLine string - // Response data - ResponseStatus int - ResponseHeaders map[string][]string - ResponseBody string - ResponseDuration time.Duration -} - -// EventHistory manages the history of events and navigation state -type EventHistory struct { - mu sync.RWMutex - events []EventInfo - selectedIndex int - userNavigated bool // Track if user has manually navigated away from latest event - eventsTitleDisplayed bool // Track if "Events" title has been displayed -} - -// NewEventHistory creates a new EventHistory instance -func NewEventHistory() *EventHistory { - return &EventHistory{ - events: make([]EventInfo, 0), - selectedIndex: -1, // Initialize to invalid index - } -} - -// AddEvent adds a new event to the history -// Returns true if the event was added, false if it was a duplicate -func (eh *EventHistory) AddEvent(eventInfo EventInfo) bool { - eh.mu.Lock() - defer eh.mu.Unlock() - - // Check if this exact event (same ID AND timestamp) already exists - // This prevents true duplicates but allows retries (same ID, different timestamp) as separate entries - for i := len(eh.events) - 1; i >= 0; i-- { - if eh.events[i].ID == eventInfo.ID && eh.events[i].Time.Equal(eventInfo.Time) { - return false // Duplicate - } - } - - // Add to history (either new event or retry with different timestamp) - eh.events = append(eh.events, eventInfo) - - // Limit history: keep only navigable events + selected event if outside range - if len(eh.events) > maxHistorySize { - // Determine which events to keep - historySize := len(eh.events) - navigableStartIdx := historySize - maxNavigableEvents - - var eventsToKeep []EventInfo - var newSelectedIndex int - - // If user has navigated to an event outside the navigable range, keep it - if eh.userNavigated && eh.selectedIndex < navigableStartIdx { - // Keep the selected event + all navigable events - eventsToKeep = make([]EventInfo, 0, maxNavigableEvents+1) - eventsToKeep = append(eventsToKeep, eh.events[eh.selectedIndex]) - eventsToKeep = append(eventsToKeep, eh.events[navigableStartIdx:]...) - newSelectedIndex = 0 // Selected event is now at index 0 - } else { - // Just keep the navigable events - eventsToKeep = eh.events[navigableStartIdx:] - // Adjust selected index relative to new array - if eh.selectedIndex >= navigableStartIdx { - newSelectedIndex = eh.selectedIndex - navigableStartIdx - } else { - // Selected event was outside range and user hasn't navigated, select latest - newSelectedIndex = len(eventsToKeep) - 1 - eh.userNavigated = false - } - } - - eh.events = eventsToKeep - eh.selectedIndex = newSelectedIndex - } else { - // Auto-select the latest event unless user has navigated away - if !eh.userNavigated { - eh.selectedIndex = len(eh.events) - 1 - } - } - - return true -} - -// GetEvents returns a copy of all events in the history -func (eh *EventHistory) GetEvents() []EventInfo { - eh.mu.RLock() - defer eh.mu.RUnlock() - - // Return a copy to prevent external modifications - eventsCopy := make([]EventInfo, len(eh.events)) - copy(eventsCopy, eh.events) - return eventsCopy -} - -// GetSelectedIndex returns the currently selected event index -func (eh *EventHistory) GetSelectedIndex() int { - eh.mu.RLock() - defer eh.mu.RUnlock() - return eh.selectedIndex -} - -// GetSelectedEvent returns a copy of the currently selected event, or nil if no event is selected -// Returns a copy to avoid issues with slice reallocation and concurrent modifications -func (eh *EventHistory) GetSelectedEvent() *EventInfo { - eh.mu.Lock() - defer eh.mu.Unlock() - - // Bounds checking with automatic correction - if len(eh.events) == 0 { - eh.selectedIndex = -1 - return nil - } - - // Fix out-of-bounds index by resetting to latest event - if eh.selectedIndex < 0 || eh.selectedIndex >= len(eh.events) { - eh.selectedIndex = len(eh.events) - 1 - eh.userNavigated = false // Reset navigation state since we're forcing a valid selection - } - - // Return a copy of the event to avoid pointer issues when slice is modified - eventCopy := eh.events[eh.selectedIndex] - return &eventCopy -} - -// IsUserNavigated returns true if the user has manually navigated away from the latest event -func (eh *EventHistory) IsUserNavigated() bool { - eh.mu.RLock() - defer eh.mu.RUnlock() - return eh.userNavigated -} - -// IsEventsTitleDisplayed returns true if the "Events" title has been displayed -func (eh *EventHistory) IsEventsTitleDisplayed() bool { - eh.mu.RLock() - defer eh.mu.RUnlock() - return eh.eventsTitleDisplayed -} - -// SetEventsTitleDisplayed sets whether the "Events" title has been displayed -func (eh *EventHistory) SetEventsTitleDisplayed(displayed bool) { - eh.mu.Lock() - defer eh.mu.Unlock() - eh.eventsTitleDisplayed = displayed -} - -// Count returns the number of events in the history -func (eh *EventHistory) Count() int { - eh.mu.RLock() - defer eh.mu.RUnlock() - return len(eh.events) -} - -// GetNavigableEvents returns the indices of events that should be shown in the "Latest events" section -// This includes the last (maxNavigableEvents-1) chronological events, plus the selected event if it's outside this range -func (eh *EventHistory) GetNavigableEvents() []int { - eh.mu.RLock() - defer eh.mu.RUnlock() - - historySize := len(eh.events) - - // Calculate the normal navigable range (last 10 events) - normalStartIdx := historySize - maxNavigableEvents - if normalStartIdx < 0 { - normalStartIdx = 0 - } - - // If user hasn't navigated or selected event is within normal range, return normal range - if !eh.userNavigated || eh.selectedIndex >= normalStartIdx { - indices := make([]int, 0, historySize-normalStartIdx) - for i := normalStartIdx; i < historySize; i++ { - indices = append(indices, i) - } - return indices - } - - // Selected event is outside normal range - include it as the first navigable event - // Show: selected event + last 9 chronological events - indices := make([]int, 0, maxNavigableEvents) - indices = append(indices, eh.selectedIndex) // Add selected event first - - // Add the last 9 events (skip one to make room for the pinned event) - startIdx := historySize - (maxNavigableEvents - 1) - if startIdx < 0 { - startIdx = 0 - } - for i := startIdx; i < historySize; i++ { - // Skip the selected event if it's also in the last 9 (edge case) - if i != eh.selectedIndex { - indices = append(indices, i) - } - } - - return indices -} - -// Navigate moves the selection up or down in the event history (within navigable events) -// direction: -1 for up, +1 for down -// Returns true if the selection changed, false otherwise -func (eh *EventHistory) Navigate(direction int) bool { - eh.mu.Lock() - defer eh.mu.Unlock() - - if len(eh.events) == 0 { - return false - } - - // Ensure selectedIndex is valid before proceeding - if eh.selectedIndex < 0 || eh.selectedIndex >= len(eh.events) { - eh.selectedIndex = len(eh.events) - 1 - eh.userNavigated = false - } - - // Calculate navigable indices (inline to avoid double-locking) - historySize := len(eh.events) - normalStartIdx := historySize - maxNavigableEvents - if normalStartIdx < 0 { - normalStartIdx = 0 - } - - var navigableIndices []int - if !eh.userNavigated || eh.selectedIndex >= normalStartIdx { - navigableIndices = make([]int, 0, historySize-normalStartIdx) - for i := normalStartIdx; i < historySize; i++ { - navigableIndices = append(navigableIndices, i) - } - } else { - navigableIndices = make([]int, 0, maxNavigableEvents) - navigableIndices = append(navigableIndices, eh.selectedIndex) - startIdx := historySize - (maxNavigableEvents - 1) - if startIdx < 0 { - startIdx = 0 - } - for i := startIdx; i < historySize; i++ { - if i != eh.selectedIndex { - navigableIndices = append(navigableIndices, i) - } - } - } - - if len(navigableIndices) == 0 { - return false - } - - // Find current position in the navigable indices - currentPos := -1 - for i, idx := range navigableIndices { - if idx == eh.selectedIndex { - currentPos = i - break - } - } - - if currentPos == -1 { - // Selected event not in navigable list (shouldn't happen), default to first - currentPos = 0 - } - - // Calculate new position - newPos := currentPos + direction - - // Clamp to navigable range - if newPos < 0 { - newPos = 0 - } else if newPos >= len(navigableIndices) { - newPos = len(navigableIndices) - 1 - } - - if newPos != currentPos { - eh.selectedIndex = navigableIndices[newPos] - eh.userNavigated = true // Mark that user has manually navigated - - // Reset userNavigated if user navigates back to the latest event - if eh.selectedIndex == len(eh.events)-1 { - eh.userNavigated = false - } - - return true - } - - return false -} diff --git a/pkg/proxy/keyboard.go b/pkg/proxy/keyboard.go deleted file mode 100644 index 6bc9282..0000000 --- a/pkg/proxy/keyboard.go +++ /dev/null @@ -1,253 +0,0 @@ -package proxy - -import ( - "context" - "os" - "os/signal" - "sync" - "syscall" - - log "github.com/sirupsen/logrus" - "golang.org/x/term" -) - -// KeyboardHandler handles keyboard input and raw mode management -type KeyboardHandler struct { - ui *TerminalUI - hasReceivedEvent *bool - isConnected *bool - showingDetails *bool - paused bool // Flag to pause input processing - pauseMutex sync.Mutex - inputCh chan []byte // Channel for buffered keyboard input - // Callbacks for actions - onNavigate func(direction int) - onRetry func() - onOpen func() - onToggleDetails func() - onQuit func() -} - -// NewKeyboardHandler creates a new KeyboardHandler instance -func NewKeyboardHandler(ui *TerminalUI, hasReceivedEvent *bool, isConnected *bool, showingDetails *bool) *KeyboardHandler { - return &KeyboardHandler{ - ui: ui, - hasReceivedEvent: hasReceivedEvent, - isConnected: isConnected, - showingDetails: showingDetails, - } -} - -// SetCallbacks sets the action callbacks -func (kh *KeyboardHandler) SetCallbacks( - onNavigate func(direction int), - onRetry func(), - onOpen func(), - onToggleDetails func(), - onQuit func(), -) { - kh.onNavigate = onNavigate - kh.onRetry = onRetry - kh.onOpen = onOpen - kh.onToggleDetails = onToggleDetails - kh.onQuit = onQuit -} - -// Pause temporarily stops processing keyboard input (while less is running) -func (kh *KeyboardHandler) Pause() { - kh.pauseMutex.Lock() - defer kh.pauseMutex.Unlock() - kh.paused = true - log.WithField("prefix", "KeyboardHandler.Pause").Debug("Keyboard input paused") -} - -// Resume resumes processing keyboard input (after less exits) -func (kh *KeyboardHandler) Resume() { - kh.pauseMutex.Lock() - defer kh.pauseMutex.Unlock() - kh.paused = false - log.WithField("prefix", "KeyboardHandler.Resume").Debug("Keyboard input resumed") -} - -// DrainBufferedInput discards any input that was buffered while paused -// This should be called after less exits but before Resume() to prevent -// keypresses meant for less from being processed by the app -func (kh *KeyboardHandler) DrainBufferedInput() { - if kh.inputCh == nil { - return - } - // Drain the channel non-blockingly - drained := 0 - for { - select { - case <-kh.inputCh: - drained++ - default: - // Channel is empty - if drained > 0 { - log.WithField("prefix", "KeyboardHandler.DrainBufferedInput").Debugf("Drained %d buffered inputs", drained) - } - return - } - } -} - -// Start begins listening for keyboard input and terminal resize signals -func (kh *KeyboardHandler) Start(ctx context.Context) { - // Check if we're in a terminal - if !term.IsTerminal(int(os.Stdin.Fd())) { - return - } - - // Set up terminal resize signal handler - sigwinchCh := make(chan os.Signal, 1) - signal.Notify(sigwinchCh, syscall.SIGWINCH) - - // Start goroutine to handle terminal resize signals - go func() { - for { - select { - case <-ctx.Done(): - signal.Stop(sigwinchCh) - close(sigwinchCh) - return - case <-sigwinchCh: - // Terminal was resized - trigger a redraw with new dimensions - log.WithField("prefix", "KeyboardHandler.Start").Debug("Terminal resize detected") - kh.ui.HandleResize(*kh.hasReceivedEvent) - } - } - }() - - go func() { - // Enter raw mode once and keep it - oldState, err := term.MakeRaw(int(os.Stdin.Fd())) - if err != nil { - return - } - - // Store the raw mode state for use in UI rendering - kh.ui.SetRawModeState(oldState) - - // Ensure we restore terminal state when this goroutine exits - defer term.Restore(int(os.Stdin.Fd()), oldState) - - // Create a buffered channel for reading stdin and store it as a field - kh.inputCh = make(chan []byte, 1) - - // Start a separate goroutine to read from stdin - go func() { - defer close(kh.inputCh) - buf := make([]byte, 3) // Buffer for escape sequences - for { - select { - case <-ctx.Done(): - return - default: - n, err := os.Stdin.Read(buf) - if err != nil { - // Log the error but don't crash the application - log.WithField("prefix", "proxy.KeyboardHandler.Start").Debugf("Error reading stdin: %v", err) - return - } - if n == 0 { - continue - } - select { - case kh.inputCh <- buf[:n]: - case <-ctx.Done(): - return - } - } - } - }() - - // Main loop to process keyboard input - for { - select { - case <-ctx.Done(): - return - case input, ok := <-kh.inputCh: - if !ok { - return - } - - // Process the input - kh.processInput(input) - } - } - }() -} - -// processInput handles keyboard input including arrow keys -func (kh *KeyboardHandler) processInput(input []byte) { - if len(input) == 0 { - return - } - - // Check if input processing is paused (e.g., while less is running) - kh.pauseMutex.Lock() - paused := kh.paused - kh.pauseMutex.Unlock() - - if paused { - // Discard all input while paused - log.WithField("prefix", "KeyboardHandler.processInput").Debugf("Discarding input while paused: %v", input) - return - } - - // Handle single character keys - if len(input) == 1 { - switch input[0] { - case 0x03: // Ctrl+C - if kh.onQuit != nil { - kh.onQuit() - } - return - } - } - - // Disable all other shortcuts until first event is received or while not connected - if !*kh.hasReceivedEvent || !*kh.isConnected { - return - } - - // Handle escape sequences (arrow keys) - if len(input) == 3 && input[0] == 0x1B && input[1] == 0x5B { - // Disable navigation while in details view - if *kh.showingDetails { - return - } - - switch input[2] { - case 0x41: // Up arrow - if kh.onNavigate != nil { - kh.onNavigate(-1) - } - case 0x42: // Down arrow - if kh.onNavigate != nil { - kh.onNavigate(1) - } - } - return - } - - // Handle single character keys (after quit/ctrl+c check) - if len(input) == 1 { - switch input[0] { - case 0x72, 0x52: // 'r' or 'R' - if !*kh.showingDetails && kh.onRetry != nil { - kh.onRetry() - } - case 0x6F, 0x4F: // 'o' or 'O' - if !*kh.showingDetails && kh.onOpen != nil { - kh.onOpen() - } - case 0x64, 0x44: // 'd' or 'D' - // Toggle alternate screen details view (but not while already showing) - if !*kh.showingDetails && kh.onToggleDetails != nil { - kh.onToggleDetails() - } - } - } -} diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index b052a33..97dea02 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -2,36 +2,15 @@ package proxy import ( "context" - "crypto/tls" - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "math" - "net/http" "net/url" "os" "os/signal" - "strconv" - "strings" "syscall" - "time" log "github.com/sirupsen/logrus" - - "github.com/hookdeck/hookdeck-cli/pkg/ansi" - "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" - "github.com/hookdeck/hookdeck-cli/pkg/websocket" - hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" ) const timeLayout = "2006-01-02 15:04:05" -const maxNavigableEvents = 10 // Only last 10 events are navigable -const maxHistorySize = maxNavigableEvents + 1 // Keep navigable events + 1 for selected event outside range - -// -// Public types -// // Config provides the configuration of a Proxy type Config struct { @@ -51,32 +30,12 @@ type Config struct { NoWSS bool Insecure bool // Output mode: interactive, compact, quiet - Output string -} - -// A Proxy opens a websocket connection with Hookdeck, listens for incoming -// webhook events, forwards them to the local endpoint and sends the response -// back to Hookdeck. -type Proxy struct { - cfg *Config - connections []*hookdecksdk.Connection - webSocketClient *websocket.Client - connectionTimer *time.Timer - hasReceivedEvent bool - stopWaitingAnimation chan bool - // UI and event management - ui *TerminalUI - eventHistory *EventHistory - keyboardHandler *KeyboardHandler - eventActions *EventActions - // Details view - showingDetails bool // Track if we're in alternate screen showing details - // Connection state - isConnected bool // Track if we're currently connected (disable actions during reconnection) + Output string + GuestURL string } +// withSIGTERMCancel creates a context that will be canceled when Ctrl+C is pressed func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { - // Create a context that will be canceled when Ctrl+C is pressed ctx, cancel := context.WithCancel(ctx) interruptCh := make(chan os.Signal, 1) @@ -89,539 +48,3 @@ func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { }() return ctx } - -// printEventAndUpdateStatus prints the event log and updates the status line in one operation -func (p *Proxy) printEventAndUpdateStatus(eventID string, status int, success bool, eventTime time.Time, eventData *websocket.Attempt, eventLog string, responseStatus int, responseHeaders map[string][]string, responseBody string, responseDuration time.Duration) { - switch p.cfg.Output { - case "interactive": - if p.ui != nil { - // Create event info with all data passed as parameters (no shared state) - eventInfo := EventInfo{ - ID: eventID, - Status: status, - Success: success, - Time: eventTime, - Data: eventData, - LogLine: eventLog, - ResponseStatus: responseStatus, - ResponseHeaders: responseHeaders, - ResponseBody: responseBody, - ResponseDuration: responseDuration, - } - // Delegate rendering to UI (pass showingDetails flag to block rendering while less is open) - p.ui.PrintEventAndUpdateStatus(eventInfo, p.hasReceivedEvent, p.showingDetails) - } - - case "compact": - // Print all events (success and HTTP errors) - fmt.Println(eventLog) - - case "quiet": - // Only print FATAL errors (no response received - responseStatus == 0 && !success) - // HTTP 4xx/5xx responses are NOT printed (they're valid HTTP responses) - if !success && responseStatus == 0 { - fmt.Println(eventLog) - } - } -} - -// startWaitingAnimation starts an animation for the waiting indicator -func (p *Proxy) startWaitingAnimation(ctx context.Context) { - p.stopWaitingAnimation = make(chan bool, 1) - - go func() { - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-p.stopWaitingAnimation: - return - case <-ticker.C: - if !p.hasReceivedEvent && p.ui != nil { - p.ui.UpdateStatusLine(p.hasReceivedEvent) - } - } - } - }() -} - -// quit handles Ctrl+C and q key to exit the application -func (p *Proxy) quit() { - proc, _ := os.FindProcess(os.Getpid()) - proc.Signal(os.Interrupt) -} - -// toggleDetailsView shows event details (blocking until user exits less with 'q') -func (p *Proxy) toggleDetailsView() { - // Only available in interactive mode - if p.keyboardHandler == nil || p.eventActions == nil || p.ui == nil { - return - } - - // Set flag BEFORE calling ShowEventDetails, since it blocks until less exits - p.showingDetails = true - - // Pause keyboard handler to prevent it from processing keypresses meant for less - p.keyboardHandler.Pause() - - // Ensure cleanup happens after less exits - defer func() { - // Drain any buffered input that was meant for less but leaked to our keyboard handler - p.keyboardHandler.DrainBufferedInput() - - // Resume normal keyboard processing - p.keyboardHandler.Resume() - p.showingDetails = false - }() - - shown, _ := p.eventActions.ShowEventDetails() - - if shown { - // After less exits, we need to redraw the entire event list - // because less has taken over the screen - p.ui.RedrawAfterDetailsView(p.hasReceivedEvent) - } -} - -// navigateEvents moves the selection up or down in the event history (within navigable events) -func (p *Proxy) navigateEvents(direction int) { - // Only available in interactive mode - if p.eventHistory == nil || p.ui == nil { - return - } - - // Delegate to EventHistory and redraw if selection changed - if p.eventHistory.Navigate(direction) { - p.ui.RedrawEventsWithSelection(p.hasReceivedEvent) - } -} - -// Run manages the connection to Hookdeck. -// The connection is established in phases: -// - Create a new CLI session -// - Create a new websocket connection -func (p *Proxy) Run(parentCtx context.Context) error { - const maxConnectAttempts = 10 - const maxReconnectAttempts = 10 // Also limit reconnection attempts - nAttempts := 0 - - // Track whether or not we have connected successfully. - hasConnectedOnce := false - canConnect := func() bool { - if hasConnectedOnce { - // After first successful connection, allow limited reconnection attempts - return nAttempts < maxReconnectAttempts - } else { - // Initial connection attempts - return nAttempts < maxConnectAttempts - } - } - - signalCtx := withSIGTERMCancel(parentCtx, func() { - log.WithFields(log.Fields{ - "prefix": "proxy.Proxy.Run", - }).Debug("Ctrl+C received, cleaning up...") - }) - - // Start keyboard listener and waiting animation only for interactive mode - if p.cfg.Output == "interactive" && p.keyboardHandler != nil { - p.keyboardHandler.Start(signalCtx) - p.startWaitingAnimation(signalCtx) - } - - s := ansi.StartNewSpinner("Getting ready...", p.cfg.Log.Out) - - session, err := p.createSession(signalCtx) - if err != nil { - // Stop spinner before fatal error (terminal will be restored by defer) - ansi.StopSpinner(s, "", p.cfg.Log.Out) - fmt.Print("\033[2K\r") - p.cfg.Log.Fatalf("Error while authenticating with Hookdeck: %v", err) - } - - if session.Id == "" { - // Stop spinner before fatal error (terminal will be restored by defer) - ansi.StopSpinner(s, "", p.cfg.Log.Out) - fmt.Print("\033[2K\r") - p.cfg.Log.Fatalf("Error while starting a new session") - } - - // Main loop to keep attempting to connect to Hookdeck once - // we have created a session. - for canConnect() { - // Apply backoff delay BEFORE creating new client (except for first attempt) - if nAttempts > 0 { - // Exponential backoff: 100ms * 2^(attempt-1), capped at 30 seconds - // Attempt 1: 100ms, 2: 200ms, 3: 400ms, 4: 800ms, 5: 1.6s, 6: 3.2s, 7: 6.4s, 8: 12.8s, 9+: 30s - backoffMS := math.Min(100*math.Pow(2, float64(nAttempts-1)), 30000) - sleepDurationMS := int(backoffMS) - - log.WithField( - "prefix", "proxy.Proxy.Run", - ).Debugf( - "Connect backoff (%dms)", sleepDurationMS, - ) - - // Reset the timer to the next duration - p.connectionTimer.Stop() - p.connectionTimer.Reset(time.Duration(sleepDurationMS) * time.Millisecond) - - // Block with a spinner while waiting - ansi.StopSpinner(s, "", p.cfg.Log.Out) - // Use different message based on whether we've connected before - if hasConnectedOnce { - s = ansi.StartNewSpinner("Connection lost, reconnecting...", p.cfg.Log.Out) - } else { - s = ansi.StartNewSpinner("Connecting...", p.cfg.Log.Out) - } - select { - case <-p.connectionTimer.C: - // Continue to retry - case <-signalCtx.Done(): - p.connectionTimer.Stop() - ansi.StopSpinner(s, "", p.cfg.Log.Out) - return nil - } - } - - p.webSocketClient = websocket.NewClient( - p.cfg.WSBaseURL, - session.Id, - p.cfg.Key, - p.cfg.ProjectID, - &websocket.Config{ - Log: p.cfg.Log, - NoWSS: p.cfg.NoWSS, - EventHandler: websocket.EventHandlerFunc(p.processAttempt), - }, - ) - - // Monitor the websocket for connection and update the spinner appropriately. - go func() { - <-p.webSocketClient.Connected() - // Mark as connected and reset attempt counter - p.isConnected = true - nAttempts = 0 - - // Stop the spinner and clear its line - ansi.StopSpinner(s, "", p.cfg.Log.Out) - - // Show connection status based on output mode - if p.ui != nil { - // Interactive mode: clear the spinner line (blank line already exists from listen.go) - fmt.Print("\033[2K\r") // Clear current line (where spinner was) - p.ui.UpdateStatusLine(p.hasReceivedEvent) - } else { - // Compact/quiet mode: print simple connection status - color := ansi.Color(os.Stdout) - fmt.Printf("%s\n\n", color.Faint("Connected. Waiting for events...")) - } - - hasConnectedOnce = true - }() - - // Run the websocket in the background - go p.webSocketClient.Run(signalCtx) - nAttempts++ - - // Block until ctrl+c or the websocket connection is interrupted - select { - case <-signalCtx.Done(): - ansi.StopSpinner(s, "", p.cfg.Log.Out) - return nil - case <-p.webSocketClient.NotifyExpired: - // Mark as disconnected - p.isConnected = false - - if !canConnect() { - // Stop the spinner before fatal error (terminal will be restored by defer) - ansi.StopSpinner(s, "", p.cfg.Log.Out) - - // In interactive mode, need to clear the status line and move to a new line - if p.ui != nil { - // Temporarily restore terminal to print the error properly - p.ui.TemporarilyRestoreNormalMode() - // Move cursor up to status line and clear it, then move down - fmt.Print("\033[1A\033[2K\r\n") - } else { - // Non-interactive mode: just clear the spinner line - fmt.Print("\033[2K\r") - } - - // Print error without timestamp (use fmt instead of log to avoid formatter) - color := ansi.Color(os.Stdout) - fmt.Fprintf(os.Stderr, "%s Could not establish connection. Terminating after %d attempts to connect.\n", - color.Red("FATAL"), nAttempts) - os.Exit(1) - } - // Connection lost, loop will retry (backoff happens at start of next iteration) - } - } - - if p.webSocketClient != nil { - p.webSocketClient.Stop() - } - - log.WithFields(log.Fields{ - "prefix": "proxy.Proxy.Run", - }).Debug("Bye!") - - return nil -} - -func (p *Proxy) createSession(ctx context.Context) (hookdeck.Session, error) { - var session hookdeck.Session - - parsedBaseURL, err := url.Parse(p.cfg.APIBaseURL) - if err != nil { - return session, err - } - - client := &hookdeck.Client{ - BaseURL: parsedBaseURL, - APIKey: p.cfg.Key, - ProjectID: p.cfg.ProjectID, - } - - var connectionIDs []string - for _, connection := range p.connections { - connectionIDs = append(connectionIDs, connection.Id) - } - - for i := 0; i <= 5; i++ { - session, err = client.CreateSession(hookdeck.CreateSessionInput{ - ConnectionIds: connectionIDs, - }) - - if err == nil { - return session, nil - } - - select { - case <-ctx.Done(): - return session, errors.New("canceled by context") - case <-time.After(1 * time.Second): - } - } - - return session, err -} - -func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { - if msg.Attempt == nil { - p.cfg.Log.Debug("WebSocket specified for Events received unexpected event") - return - } - - webhookEvent := msg.Attempt - eventID := webhookEvent.Body.EventID - - p.cfg.Log.WithFields(log.Fields{ - "prefix": "proxy.Proxy.processAttempt", - }).Debugf("Processing webhook event") - - url := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: p.cfg.Insecure}, - } - - timeout := webhookEvent.Body.Request.Timeout - if timeout == 0 { - timeout = 1000 * 30 - } - - client := &http.Client{ - Timeout: time.Duration(timeout) * time.Millisecond, - Transport: tr, - } - - req, err := http.NewRequest(webhookEvent.Body.Request.Method, url, nil) - if err != nil { - // Handle error gracefully - only print in interactive mode - if p.ui != nil { - p.ui.SafePrintf("Error: %s\n", err) - } - return - } - x := make(map[string]json.RawMessage) - err = json.Unmarshal(webhookEvent.Body.Request.Headers, &x) - if err != nil { - // Handle error gracefully - only print in interactive mode - if p.ui != nil { - p.ui.SafePrintf("Error: %s\n", err) - } - return - } - - for key, value := range x { - unquoted_value, _ := strconv.Unquote(string(value)) - req.Header.Set(key, unquoted_value) - } - - req.Body = ioutil.NopCloser(strings.NewReader(webhookEvent.Body.Request.DataString)) - req.ContentLength = int64(len(webhookEvent.Body.Request.DataString)) - - // Track request start time for duration calculation - requestStartTime := time.Now() - - res, err := client.Do(req) - - if err != nil { - color := ansi.Color(os.Stdout) - localTime := time.Now().Format(timeLayout) - - errStr := fmt.Sprintf("%s [%s] Failed to %s: %v", - color.Faint(localTime), - color.Red("ERROR").Bold(), - webhookEvent.Body.Request.Method, - err, - ) - - // Mark as having received first event - if !p.hasReceivedEvent { - p.hasReceivedEvent = true - // Stop the waiting animation - if p.stopWaitingAnimation != nil { - p.stopWaitingAnimation <- true - } - } - - // Print the error and update status line with event-specific data (no response data for errors) - p.printEventAndUpdateStatus(eventID, 0, false, time.Now(), webhookEvent, errStr, 0, nil, "", 0) - - p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ - ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ - Event: "attempt_response", - Body: websocket.ErrorAttemptBody{ - AttemptId: webhookEvent.Body.AttemptId, - Error: true, - }, - }}) - } else { - p.processEndpointResponse(webhookEvent, res, requestStartTime) - } -} - -func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *http.Response, requestStartTime time.Time) { - eventTime := time.Now() - localTime := eventTime.Format(timeLayout) - color := ansi.Color(os.Stdout) - var url = p.cfg.DashboardBaseURL + "/events/" + webhookEvent.Body.EventID - if p.cfg.ProjectMode == "console" { - url = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID - } - - // Calculate response duration - responseDuration := eventTime.Sub(requestStartTime) - durationMs := responseDuration.Milliseconds() - - outputStr := fmt.Sprintf("%s [%d] %s %s %s %s %s", - color.Faint(localTime), - ansi.ColorizeStatus(resp.StatusCode), - resp.Request.Method, - resp.Request.URL, - color.Faint(fmt.Sprintf("(%dms)", durationMs)), - color.Faint("→"), - color.Faint(url), - ) - - // Calculate event status - eventStatus := resp.StatusCode - eventSuccess := resp.StatusCode >= 200 && resp.StatusCode < 300 - eventID := webhookEvent.Body.EventID - - // Read response body - buf, err := ioutil.ReadAll(resp.Body) - if err != nil { - errStr := fmt.Sprintf("%s [%s] Failed to read response from endpoint, error = %v\n", - color.Faint(localTime), - color.Red("ERROR").Bold(), - err, - ) - log.Errorf(errStr) - - return - } - - // Capture response data - responseStatus := resp.StatusCode - responseHeaders := make(map[string][]string) - for key, values := range resp.Header { - responseHeaders[key] = values - } - responseBody := string(buf) - - // Mark as having received first event - if !p.hasReceivedEvent { - p.hasReceivedEvent = true - // Stop the waiting animation - if p.stopWaitingAnimation != nil { - p.stopWaitingAnimation <- true - } - } - - // Print the event log and update status line with event-specific data including response - p.printEventAndUpdateStatus(eventID, eventStatus, eventSuccess, eventTime, webhookEvent, outputStr, responseStatus, responseHeaders, responseBody, responseDuration) - - if p.webSocketClient != nil { - p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ - AttemptResponse: &websocket.AttemptResponse{ - Event: "attempt_response", - Body: websocket.AttemptResponseBody{ - AttemptId: webhookEvent.Body.AttemptId, - CLIPath: webhookEvent.Body.Path, - Status: resp.StatusCode, - Data: string(buf), - }, - }}) - } -} - -// -// Public functions -// - -// New creates a new Proxy -func New(cfg *Config, connections []*hookdecksdk.Connection) *Proxy { - if cfg.Log == nil { - cfg.Log = &log.Logger{Out: ioutil.Discard} - } - - // Default to interactive mode if not specified - if cfg.Output == "" { - cfg.Output = "interactive" - } - - p := &Proxy{ - cfg: cfg, - connections: connections, - connectionTimer: time.NewTimer(0), // Defaults to no delay - } - - // Only create interactive components for interactive mode - if cfg.Output == "interactive" { - eventHistory := NewEventHistory() - ui := NewTerminalUI(eventHistory) - - p.eventHistory = eventHistory - p.ui = ui - - // Create event actions handler - p.eventActions = NewEventActions(cfg, eventHistory, ui) - - // Create keyboard handler and set up callbacks - p.keyboardHandler = NewKeyboardHandler(ui, &p.hasReceivedEvent, &p.isConnected, &p.showingDetails) - p.keyboardHandler.SetCallbacks( - p.navigateEvents, - p.eventActions.RetrySelectedEvent, - p.eventActions.OpenSelectedEventURL, - p.toggleDetailsView, - p.quit, - ) - } - - return p -} diff --git a/pkg/proxy/proxy_tui.go b/pkg/proxy/proxy_tui.go new file mode 100644 index 0000000..16f6855 --- /dev/null +++ b/pkg/proxy/proxy_tui.go @@ -0,0 +1,741 @@ +package proxy + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "math" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/briandowns/spinner" + tea "github.com/charmbracelet/bubbletea" + log "github.com/sirupsen/logrus" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/tui" + "github.com/hookdeck/hookdeck-cli/pkg/websocket" + hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" +) + +// ProxyTUI is a Proxy that uses Bubble Tea for interactive mode +type ProxyTUI struct { + cfg *Config + connections []*hookdecksdk.Connection + webSocketClient *websocket.Client + connectionTimer *time.Timer + + // Bubble Tea program + teaProgram *tea.Program + teaModel *tui.Model +} + +// NewTUI creates a new Proxy with Bubble Tea UI +func NewTUI(cfg *Config, sources []*hookdecksdk.Source, connections []*hookdecksdk.Connection) *ProxyTUI { + if cfg.Log == nil { + cfg.Log = &log.Logger{Out: ioutil.Discard} + } + + // Default to interactive mode if not specified + if cfg.Output == "" { + cfg.Output = "interactive" + } + + p := &ProxyTUI{ + cfg: cfg, + connections: connections, + connectionTimer: time.NewTimer(0), + } + + // Only create Bubble Tea program for interactive mode + if cfg.Output == "interactive" { + tuiCfg := &tui.Config{ + DeviceName: cfg.DeviceName, + APIKey: cfg.Key, + APIBaseURL: cfg.APIBaseURL, + DashboardBaseURL: cfg.DashboardBaseURL, + ConsoleBaseURL: cfg.ConsoleBaseURL, + ProjectMode: cfg.ProjectMode, + ProjectID: cfg.ProjectID, + GuestURL: cfg.GuestURL, + TargetURL: cfg.URL, + Sources: sources, + Connections: connections, + } + model := tui.NewModel(tuiCfg) + p.teaModel = &model + // Use alt screen to keep terminal clean + p.teaProgram = tea.NewProgram(&model, tea.WithAltScreen()) + } + + return p +} + +// Run manages the connection to Hookdeck with Bubble Tea UI +func (p *ProxyTUI) Run(parentCtx context.Context) error { + const maxConnectAttempts = 10 + const maxReconnectAttempts = 10 + nAttempts := 0 + + hasConnectedOnce := false + canConnect := func() bool { + if hasConnectedOnce { + return nAttempts < maxReconnectAttempts + } + return nAttempts < maxConnectAttempts + } + + signalCtx := withSIGTERMCancel(parentCtx, func() { + log.WithFields(log.Fields{ + "prefix": "proxy.ProxyTUI.Run", + }).Debug("Ctrl+C received, cleaning up...") + }) + + // Create a channel to signal when TUI exits + tuiDoneCh := make(chan struct{}) + + // Start Bubble Tea program in interactive mode immediately + if p.cfg.Output == "interactive" && p.teaProgram != nil { + go func() { + if _, err := p.teaProgram.Run(); err != nil { + log.WithField("prefix", "proxy.ProxyTUI.Run"). + Errorf("Bubble Tea error: %v", err) + } + // Signal that TUI has exited (user pressed q or Ctrl+C) + close(tuiDoneCh) + }() + } + + // For non-interactive modes, show spinner + var s *spinner.Spinner + if p.cfg.Output != "interactive" { + s = ansi.StartNewSpinner("Getting ready...", p.cfg.Log.Out) + } + + // Send connecting message to TUI + if p.teaProgram != nil { + p.teaProgram.Send(tui.ConnectingMsg{}) + } + + session, err := p.createSession(signalCtx) + if err != nil { + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + } + if p.teaProgram != nil { + p.teaProgram.Kill() + } + p.cfg.Log.Fatalf("Error while authenticating with Hookdeck: %v", err) + } + + if session.Id == "" { + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + } + if p.teaProgram != nil { + p.teaProgram.Kill() + } + p.cfg.Log.Fatalf("Error while starting a new session") + } + + // Main connection loop + for canConnect() { + // Apply backoff delay + if nAttempts > 0 { + backoffMS := math.Min(100*math.Pow(2, float64(nAttempts-1)), 30000) + sleepDurationMS := int(backoffMS) + + log.WithField("prefix", "proxy.ProxyTUI.Run"). + Debugf("Connect backoff (%dms)", sleepDurationMS) + + p.connectionTimer.Stop() + p.connectionTimer.Reset(time.Duration(sleepDurationMS) * time.Millisecond) + + // For non-interactive modes, update spinner + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + if hasConnectedOnce { + s = ansi.StartNewSpinner("Connection lost, reconnecting...", p.cfg.Log.Out) + } else { + s = ansi.StartNewSpinner("Connecting...", p.cfg.Log.Out) + } + } + + // For interactive mode, send reconnecting message to TUI + if p.teaProgram != nil { + if hasConnectedOnce { + p.teaProgram.Send(tui.DisconnectedMsg{}) + } else { + p.teaProgram.Send(tui.ConnectingMsg{}) + } + } + + select { + case <-p.connectionTimer.C: + // Continue to retry + case <-signalCtx.Done(): + p.connectionTimer.Stop() + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + } + if p.teaProgram != nil { + p.teaProgram.Kill() + } + return nil + case <-tuiDoneCh: + // TUI exited during backoff + p.connectionTimer.Stop() + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + } + if p.webSocketClient != nil { + p.webSocketClient.Stop() + } + return nil + } + } + + p.webSocketClient = websocket.NewClient( + p.cfg.WSBaseURL, + session.Id, + p.cfg.Key, + p.cfg.ProjectID, + &websocket.Config{ + Log: p.cfg.Log, + NoWSS: p.cfg.NoWSS, + EventHandler: websocket.EventHandlerFunc(p.processAttempt), + }, + ) + + // Monitor websocket connection + go func() { + <-p.webSocketClient.Connected() + nAttempts = 0 + + // For non-interactive modes, stop spinner and show message + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + color := ansi.Color(os.Stdout) + fmt.Printf("%s\n\n", color.Faint("Connected. Waiting for events...")) + } + + // Send connected message to TUI + if p.teaProgram != nil { + p.teaProgram.Send(tui.ConnectedMsg{}) + } + + hasConnectedOnce = true + }() + + // Run websocket + go p.webSocketClient.Run(signalCtx) + nAttempts++ + + // Block until ctrl+c, TUI exits, or connection lost + select { + case <-signalCtx.Done(): + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + } + if p.teaProgram != nil { + p.teaProgram.Kill() + } + return nil + case <-tuiDoneCh: + // TUI exited (user pressed q or Ctrl+C in TUI) + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + } + if p.webSocketClient != nil { + p.webSocketClient.Stop() + } + return nil + case <-p.webSocketClient.NotifyExpired: + // Send disconnected message + if p.teaProgram != nil { + p.teaProgram.Send(tui.DisconnectedMsg{}) + } + + if !canConnect() { + if s != nil { + ansi.StopSpinner(s, "", p.cfg.Log.Out) + } + if p.teaProgram != nil { + p.teaProgram.Quit() + // Wait a moment for TUI to clean up properly + select { + case <-tuiDoneCh: + // TUI exited cleanly + case <-time.After(100 * time.Millisecond): + // Timeout, force kill + p.teaProgram.Kill() + } + } + + return fmt.Errorf("Could not establish connection. Terminating after %d attempts to connect", nAttempts) + } + } + } + + if p.webSocketClient != nil { + p.webSocketClient.Stop() + } + + if p.teaProgram != nil { + p.teaProgram.Kill() + } + + log.WithFields(log.Fields{ + "prefix": "proxy.ProxyTUI.Run", + }).Debug("Bye!") + + return nil +} + +func (p *ProxyTUI) createSession(ctx context.Context) (hookdeck.Session, error) { + var session hookdeck.Session + + parsedBaseURL, err := url.Parse(p.cfg.APIBaseURL) + if err != nil { + return session, err + } + + client := &hookdeck.Client{ + BaseURL: parsedBaseURL, + APIKey: p.cfg.Key, + ProjectID: p.cfg.ProjectID, + } + + var connectionIDs []string + for _, connection := range p.connections { + connectionIDs = append(connectionIDs, connection.Id) + } + + for i := 0; i <= 5; i++ { + session, err = client.CreateSession(hookdeck.CreateSessionInput{ + ConnectionIds: connectionIDs, + }) + + if err == nil { + return session, nil + } + + select { + case <-ctx.Done(): + return session, errors.New("canceled by context") + case <-time.After(1 * time.Second): + } + } + + return session, err +} + +func (p *ProxyTUI) processAttempt(msg websocket.IncomingMessage) { + if msg.Attempt == nil { + p.cfg.Log.Debug("WebSocket specified for Events received unexpected event") + return + } + + webhookEvent := msg.Attempt + eventID := webhookEvent.Body.EventID + + p.cfg.Log.WithFields(log.Fields{ + "prefix": "proxy.ProxyTUI.processAttempt", + }).Debugf("Processing webhook event") + + url := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: p.cfg.Insecure}, + } + + timeout := webhookEvent.Body.Request.Timeout + if timeout == 0 { + timeout = 1000 * 30 + } + + client := &http.Client{ + Timeout: time.Duration(timeout) * time.Millisecond, + Transport: tr, + } + + req, err := http.NewRequest(webhookEvent.Body.Request.Method, url, nil) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + x := make(map[string]json.RawMessage) + err = json.Unmarshal(webhookEvent.Body.Request.Headers, &x) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + for key, value := range x { + unquoted_value, _ := strconv.Unquote(string(value)) + req.Header.Set(key, unquoted_value) + } + + req.Body = ioutil.NopCloser(strings.NewReader(webhookEvent.Body.Request.DataString)) + req.ContentLength = int64(len(webhookEvent.Body.Request.DataString)) + + // Start 100ms timer and HTTP request concurrently + requestStartTime := time.Now() + eventTime := requestStartTime + + // Channel to receive HTTP response or error + type httpResult struct { + res *http.Response + err error + } + responseCh := make(chan httpResult, 1) + + // Make HTTP request in goroutine + go func() { + res, err := client.Do(req) + responseCh <- httpResult{res: res, err: err} + }() + + // Wait for either 100ms to pass or HTTP response to arrive + timer := time.NewTimer(100 * time.Millisecond) + defer timer.Stop() + + var eventShown bool + var result httpResult + + select { + case result = <-responseCh: + // Response came back within 100ms - show final event immediately + timer.Stop() + if result.err != nil { + p.handleRequestError(eventID, webhookEvent, result.err) + } else { + p.processEndpointResponse(webhookEvent, result.res, requestStartTime) + } + return + + case <-timer.C: + // 100ms passed - show pending event + eventShown = true + p.showPendingEvent(eventID, webhookEvent, eventTime) + + // Wait for HTTP response to complete + result = <-responseCh + } + + // If we showed pending event, now update it with final result + if eventShown { + if result.err != nil { + p.updateEventWithError(eventID, webhookEvent, result.err, eventTime) + } else { + p.updateEventWithResponse(eventID, webhookEvent, result.res, requestStartTime, eventTime) + } + } +} + +func (p *ProxyTUI) showPendingEvent(eventID string, webhookEvent *websocket.Attempt, eventTime time.Time) { + color := ansi.Color(os.Stdout) + localTime := eventTime.Format(timeLayout) + + pendingStr := fmt.Sprintf("%s [%s] %s %s %s", + color.Faint(localTime), + color.Faint("..."), + webhookEvent.Body.Request.Method, + fmt.Sprintf("http://localhost%s", webhookEvent.Body.Path), + color.Faint("(Waiting for response)"), + ) + + // Send pending event to UI + event := tui.EventInfo{ + ID: eventID, + AttemptID: webhookEvent.Body.AttemptId, + Status: 0, + Success: false, + Time: eventTime, + Data: webhookEvent, + LogLine: pendingStr, + ResponseStatus: 0, + ResponseDuration: 0, + } + + switch p.cfg.Output { + case "interactive": + if p.teaProgram != nil { + p.teaProgram.Send(tui.NewEventMsg{Event: event}) + } + case "compact": + fmt.Println(pendingStr) + case "quiet": + // Don't show pending events in quiet mode + } +} + +func (p *ProxyTUI) updateEventWithError(eventID string, webhookEvent *websocket.Attempt, err error, eventTime time.Time) { + color := ansi.Color(os.Stdout) + localTime := eventTime.Format(timeLayout) + + errStr := fmt.Sprintf("%s [%s] Failed to %s: %v", + color.Faint(localTime), + color.Red("ERROR").Bold(), + webhookEvent.Body.Request.Method, + err, + ) + + // Update event in UI + switch p.cfg.Output { + case "interactive": + if p.teaProgram != nil { + p.teaProgram.Send(tui.UpdateEventMsg{ + EventID: eventID, + Time: eventTime, + Status: 0, + Success: false, + LogLine: errStr, + ResponseStatus: 0, + ResponseHeaders: nil, + ResponseBody: "", + ResponseDuration: 0, + }) + } + case "compact": + fmt.Println(errStr) + case "quiet": + fmt.Println(errStr) + } + + p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ + ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ + Event: "attempt_response", + Body: websocket.ErrorAttemptBody{ + AttemptId: webhookEvent.Body.AttemptId, + Error: true, + }, + }}) +} + +func (p *ProxyTUI) updateEventWithResponse(eventID string, webhookEvent *websocket.Attempt, resp *http.Response, requestStartTime time.Time, eventTime time.Time) { + localTime := eventTime.Format(timeLayout) + color := ansi.Color(os.Stdout) + + // Build display URL (without team_id for cleaner display) + var displayURL string + if p.cfg.ProjectMode == "console" { + displayURL = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID + } else { + displayURL = p.cfg.DashboardBaseURL + "/events/" + webhookEvent.Body.EventID + } + + responseDuration := eventTime.Sub(requestStartTime) + durationMs := responseDuration.Milliseconds() + + outputStr := fmt.Sprintf("%s [%d] %s %s %s %s %s", + color.Faint(localTime), + ansi.ColorizeStatus(resp.StatusCode), + resp.Request.Method, + resp.Request.URL, + color.Faint(fmt.Sprintf("(%dms)", durationMs)), + color.Faint("→"), + color.Faint(displayURL), + ) + + eventStatus := resp.StatusCode + eventSuccess := resp.StatusCode >= 200 && resp.StatusCode < 300 + + buf, err := ioutil.ReadAll(resp.Body) + if err != nil { + errStr := fmt.Sprintf("%s [%s] Failed to read response from endpoint, error = %v\n", + color.Faint(localTime), + color.Red("ERROR").Bold(), + err, + ) + log.Errorf(errStr) + return + } + + responseHeaders := make(map[string][]string) + for key, values := range resp.Header { + responseHeaders[key] = values + } + responseBody := string(buf) + + // Update event in UI + switch p.cfg.Output { + case "interactive": + if p.teaProgram != nil { + p.teaProgram.Send(tui.UpdateEventMsg{ + EventID: eventID, + Time: eventTime, + Status: eventStatus, + Success: eventSuccess, + LogLine: outputStr, + ResponseStatus: eventStatus, + ResponseHeaders: responseHeaders, + ResponseBody: responseBody, + ResponseDuration: responseDuration, + }) + } + case "compact": + fmt.Println(outputStr) + case "quiet": + // Only print fatal errors + if !eventSuccess && eventStatus == 0 { + fmt.Println(outputStr) + } + } + + if p.webSocketClient != nil { + p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ + AttemptResponse: &websocket.AttemptResponse{ + Event: "attempt_response", + Body: websocket.AttemptResponseBody{ + AttemptId: webhookEvent.Body.AttemptId, + CLIPath: webhookEvent.Body.Path, + Status: resp.StatusCode, + Data: string(buf), + }, + }}) + } +} + +func (p *ProxyTUI) handleRequestError(eventID string, webhookEvent *websocket.Attempt, err error) { + color := ansi.Color(os.Stdout) + localTime := time.Now().Format(timeLayout) + + errStr := fmt.Sprintf("%s [%s] Failed to %s: %v", + color.Faint(localTime), + color.Red("ERROR").Bold(), + webhookEvent.Body.Request.Method, + err, + ) + + // Send event to UI + event := tui.EventInfo{ + ID: eventID, + AttemptID: webhookEvent.Body.AttemptId, + Status: 0, + Success: false, + Time: time.Now(), + Data: webhookEvent, + LogLine: errStr, + ResponseStatus: 0, + ResponseDuration: 0, + } + + switch p.cfg.Output { + case "interactive": + if p.teaProgram != nil { + p.teaProgram.Send(tui.NewEventMsg{Event: event}) + } + case "compact": + fmt.Println(errStr) + case "quiet": + fmt.Println(errStr) + } + + p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ + ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ + Event: "attempt_response", + Body: websocket.ErrorAttemptBody{ + AttemptId: webhookEvent.Body.AttemptId, + Error: true, + }, + }}) +} + +func (p *ProxyTUI) processEndpointResponse(webhookEvent *websocket.Attempt, resp *http.Response, requestStartTime time.Time) { + eventTime := time.Now() + localTime := eventTime.Format(timeLayout) + color := ansi.Color(os.Stdout) + + // Build display URL (without team_id for cleaner display) + var displayURL string + if p.cfg.ProjectMode == "console" { + displayURL = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID + } else { + displayURL = p.cfg.DashboardBaseURL + "/events/" + webhookEvent.Body.EventID + } + + responseDuration := eventTime.Sub(requestStartTime) + durationMs := responseDuration.Milliseconds() + + outputStr := fmt.Sprintf("%s [%d] %s %s %s %s %s", + color.Faint(localTime), + ansi.ColorizeStatus(resp.StatusCode), + resp.Request.Method, + resp.Request.URL, + color.Faint(fmt.Sprintf("(%dms)", durationMs)), + color.Faint("→"), + color.Faint(displayURL), + ) + + eventStatus := resp.StatusCode + eventSuccess := resp.StatusCode >= 200 && resp.StatusCode < 300 + eventID := webhookEvent.Body.EventID + + buf, err := ioutil.ReadAll(resp.Body) + if err != nil { + errStr := fmt.Sprintf("%s [%s] Failed to read response from endpoint, error = %v\n", + color.Faint(localTime), + color.Red("ERROR").Bold(), + err, + ) + log.Errorf(errStr) + return + } + + responseHeaders := make(map[string][]string) + for key, values := range resp.Header { + responseHeaders[key] = values + } + responseBody := string(buf) + + // Send event to UI + event := tui.EventInfo{ + ID: eventID, + AttemptID: webhookEvent.Body.AttemptId, + Status: eventStatus, + Success: eventSuccess, + Time: eventTime, + Data: webhookEvent, + LogLine: outputStr, + ResponseStatus: eventStatus, + ResponseHeaders: responseHeaders, + ResponseBody: responseBody, + ResponseDuration: responseDuration, + } + + switch p.cfg.Output { + case "interactive": + if p.teaProgram != nil { + p.teaProgram.Send(tui.NewEventMsg{Event: event}) + } + case "compact": + fmt.Println(outputStr) + case "quiet": + // Only print fatal errors + if !eventSuccess && eventStatus == 0 { + fmt.Println(outputStr) + } + } + + if p.webSocketClient != nil { + p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ + AttemptResponse: &websocket.AttemptResponse{ + Event: "attempt_response", + Body: websocket.AttemptResponseBody{ + AttemptId: webhookEvent.Body.AttemptId, + CLIPath: webhookEvent.Body.Path, + Status: resp.StatusCode, + Data: string(buf), + }, + }}) + } +} diff --git a/pkg/proxy/terminal_ui.go b/pkg/proxy/terminal_ui.go deleted file mode 100644 index 9a58d0f..0000000 --- a/pkg/proxy/terminal_ui.go +++ /dev/null @@ -1,614 +0,0 @@ -package proxy - -import ( - "fmt" - "os" - "sync" - - "github.com/hookdeck/hookdeck-cli/pkg/ansi" - "golang.org/x/term" -) - -// TerminalUI handles all terminal rendering and display logic -type TerminalUI struct { - terminalMutex sync.Mutex - rawModeState *term.State - statusLineShown bool - waitingAnimationFrame int - eventHistory *EventHistory - terminalWidth int // Cached terminal width, updated on resize -} - -// NewTerminalUI creates a new TerminalUI instance -func NewTerminalUI(eventHistory *EventHistory) *TerminalUI { - ui := &TerminalUI{ - eventHistory: eventHistory, - } - ui.updateTerminalWidth() - return ui -} - -// updateTerminalWidth updates the cached terminal width -func (ui *TerminalUI) updateTerminalWidth() { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil || width <= 0 { - width = 80 // Default fallback - } - // Enforce minimum width for usability - if width < 40 { - width = 40 - } - ui.terminalWidth = width -} - -// GetTerminalWidth returns the cached terminal width -func (ui *TerminalUI) GetTerminalWidth() int { - return ui.terminalWidth -} - -// HandleResize updates terminal width and triggers a redraw -func (ui *TerminalUI) HandleResize(hasReceivedEvent bool) { - ui.terminalMutex.Lock() - ui.updateTerminalWidth() - ui.terminalMutex.Unlock() - - // Trigger a full redraw with the new terminal width - if hasReceivedEvent && ui.eventHistory.Count() > 0 { - ui.RedrawEventsWithSelection(hasReceivedEvent) - } -} - -// SetRawModeState stores the terminal's raw mode state for safe printing -func (ui *TerminalUI) SetRawModeState(state *term.State) { - ui.rawModeState = state -} - -// TemporarilyRestoreNormalMode restores normal terminal mode (call before launching external programs like less) -func (ui *TerminalUI) TemporarilyRestoreNormalMode() { - if ui.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), ui.rawModeState) - } -} - -// ReEnableRawMode re-enables raw mode (call after external programs exit) -func (ui *TerminalUI) ReEnableRawMode() { - if ui.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } -} - -// SafePrintf temporarily disables raw mode, prints the message, then re-enables raw mode -func (ui *TerminalUI) SafePrintf(format string, args ...interface{}) { - ui.terminalMutex.Lock() - defer ui.terminalMutex.Unlock() - - // Temporarily restore normal terminal mode for printing - if ui.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), ui.rawModeState) - } - - // Print the message - fmt.Printf(format, args...) - - // Re-enable raw mode - if ui.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } -} - -// calculateEventLines calculates how many terminal lines an event log occupies -// accounting for line wrapping based on terminal width -func (ui *TerminalUI) calculateEventLines(logLine string) int { - // Use cached terminal width - width := ui.terminalWidth - if width <= 0 { - width = 80 // Default fallback - } - - // Strip ANSI codes to get actual visual length - visualLine := ansi.StripANSI(logLine) - - // Add 2 for the potential "> " prefix or " " indentation - lineLength := len(visualLine) + 2 - - // Calculate how many lines this will occupy - lines := (lineLength + width - 1) / width // Ceiling division - if lines < 1 { - lines = 1 - } - return lines -} - -// BuildStatusMessage generates the status line message based on the current state -// Adjusts verbosity based on terminal width -func (ui *TerminalUI) BuildStatusMessage(hasReceivedEvent bool) string { - color := ansi.Color(os.Stdout) - - // If no events received yet, show waiting animation - if !hasReceivedEvent { - var dot string - if ui.waitingAnimationFrame%2 == 0 { - dot = fmt.Sprintf("%s", color.Green("ā—")) - } else { - dot = fmt.Sprintf("%s", color.Green("ā—‹")) - } - ui.waitingAnimationFrame++ - return fmt.Sprintf("%s Connected. Waiting for events...", dot) - } - - // Get the selected event to show its status - selectedEvent := ui.eventHistory.GetSelectedEvent() - if selectedEvent == nil { - return "" // No events available - } - - // Determine verbosity based on terminal width - width := ui.terminalWidth - isNarrow := width < 100 // Narrow terminals get compact messages - isVeryNarrow := width < 60 // Very narrow terminals get minimal messages - - // Build status message based on terminal width - var statusMsg string - eventType := "Last event" - if ui.eventHistory.IsUserNavigated() { - eventType = "Selected event" - } - - if selectedEvent.Success { - if isVeryNarrow { - statusMsg = fmt.Sprintf("> %s %s [%d]", color.Green("āœ“"), eventType, selectedEvent.Status) - } else if isNarrow { - statusMsg = fmt.Sprintf("> %s %s succeeded [%d] | [↑↓] [r] [o] [d] [q]", - color.Green("āœ“"), eventType, selectedEvent.Status) - } else { - statusMsg = fmt.Sprintf("> %s %s succeeded with status %d | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", - color.Green("āœ“"), eventType, selectedEvent.Status) - } - } else { - statusText := "failed" - if selectedEvent.Status == 0 { - statusText = "failed with error" - } else { - statusText = fmt.Sprintf("failed with status %d", selectedEvent.Status) - } - - if isVeryNarrow { - if selectedEvent.Status == 0 { - statusMsg = fmt.Sprintf("> %s %s [ERR]", color.Red("x").Bold(), eventType) - } else { - statusMsg = fmt.Sprintf("> %s %s [%d]", color.Red("x").Bold(), eventType, selectedEvent.Status) - } - } else if isNarrow { - if selectedEvent.Status == 0 { - statusMsg = fmt.Sprintf("> %s %s failed | [↑↓] [r] [o] [d] [q]", - color.Red("x").Bold(), eventType) - } else { - statusMsg = fmt.Sprintf("> %s %s failed [%d] | [↑↓] [r] [o] [d] [q]", - color.Red("x").Bold(), eventType, selectedEvent.Status) - } - } else { - statusMsg = fmt.Sprintf("> %s %s %s | [↑↓] Navigate • [r] Retry • [o] Open in dashboard • [d] Show data • [Ctrl+C] Quit", - color.Red("x").Bold(), eventType, statusText) - } - } - - return statusMsg -} - -// UpdateStatusLine updates the bottom status line with the latest event information -func (ui *TerminalUI) UpdateStatusLine(hasReceivedEvent bool) { - ui.terminalMutex.Lock() - defer ui.terminalMutex.Unlock() - - // Only update if we haven't received any events yet (just the waiting animation) - if hasReceivedEvent { - return - } - - // Temporarily restore normal terminal mode for printing - if ui.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), ui.rawModeState) - } - - // Generate status message (waiting animation) - statusMsg := ui.BuildStatusMessage(hasReceivedEvent) - - if ui.statusLineShown { - // If we've shown a status before, move up one line and clear it - fmt.Printf("\033[1A\033[2K\r%s\n", statusMsg) - } else { - // First time showing status - fmt.Printf("%s\n", statusMsg) - ui.statusLineShown = true - } - - // Re-enable raw mode - if ui.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } -} - -// PrintEventAndUpdateStatus prints the event log and updates the status line in one operation -func (ui *TerminalUI) PrintEventAndUpdateStatus(eventInfo EventInfo, hasReceivedEvent bool, showingDetails bool) { - ui.terminalMutex.Lock() - defer ui.terminalMutex.Unlock() - - // Always add event to history (so it's available when returning from details view) - ui.eventHistory.AddEvent(eventInfo) - - // Skip all terminal rendering if details view is showing (less has control of the screen) - if showingDetails { - return - } - - // Check if this is the 11th event - need to add "Events" title before the first historical event - isEleventhEvent := ui.eventHistory.Count() == maxNavigableEvents && !ui.eventHistory.IsEventsTitleDisplayed() - - // If this is the 11th event, print the "Events" title now (before adding the event) - if isEleventhEvent { - // Temporarily restore normal terminal mode for printing - if ui.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), ui.rawModeState) - } - - // Move up to clear status line and blank line - fmt.Print("\033[2A\033[2K\r\033[1B\033[2K\r\033[1A") - - // Print "Events" title with newline above - color := ansi.Color(os.Stdout) - fmt.Printf("\n%s\n\n", color.Faint("Events")) - - // Print blank line and status that will be replaced - fmt.Println() - statusMsg := fmt.Sprintf("%s Adding...", color.Faint("ā—")) - fmt.Printf("%s\n", statusMsg) - - ui.eventHistory.SetEventsTitleDisplayed(true) - - // Re-enable raw mode - if ui.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } - } - - // Check if any event will exit the navigable window when we add this new event - // We need to remove indentation from events becoming immutable - needToRedrawForExitingEvents := false - if ui.eventHistory.Count() >= maxNavigableEvents { - needToRedrawForExitingEvents = true - } - - // Check if we need to redraw due to selection changes - needToClearOldSelection := false - if ui.eventHistory.IsUserNavigated() && ui.eventHistory.Count() > 0 { - // Calculate what the navigable range will be after adding the new event - futureHistorySize := ui.eventHistory.Count() + 1 - futureNavigableStartIdx := futureHistorySize - maxNavigableEvents - if futureNavigableStartIdx < 0 { - futureNavigableStartIdx = 0 - } - - // If current selection will be outside future navigable range, we need to redraw - // (The selected event will be pinned in the display, breaking chronological order) - if ui.eventHistory.GetSelectedIndex() < futureNavigableStartIdx { - needToClearOldSelection = true - } - } - - // Redraw navigable window if events are exiting or selection is being cleared - // BUT skip if we just printed the Events title (11th event case) - if (needToRedrawForExitingEvents || needToClearOldSelection) && !isEleventhEvent { - // Temporarily restore normal terminal mode for printing - if ui.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), ui.rawModeState) - } - - events := ui.eventHistory.GetEvents() - selectedIndex := ui.eventHistory.GetSelectedIndex() - - // Calculate current navigable window - currentNavigableStartIdx := len(events) - maxNavigableEvents - if currentNavigableStartIdx < 0 { - currentNavigableStartIdx = 0 - } - currentNumNavigableEvents := len(events) - currentNavigableStartIdx - - // Calculate future navigable window to determine which event will become immutable - futureHistorySize := len(events) + 1 - futureNavigableStartIdx := futureHistorySize - maxNavigableEvents - if futureNavigableStartIdx < 0 { - futureNavigableStartIdx = 0 - } - - // Move cursor up and clear - // Account for: navigable events + separator (3 lines if present) + blank + status - linesToMoveUp := currentNumNavigableEvents + 2 // events + blank + status - // If we'll have a separator, add 3 more lines (blank line + "Recent events" + blank line) - if futureNavigableStartIdx > 0 { - linesToMoveUp += 3 - } - fmt.Printf("\033[%dA", linesToMoveUp) - fmt.Print("\033[J") - - // NOTE: We NEVER redraw the "Events" title - it was printed once and stays permanent - - // Redraw events - for i := currentNavigableStartIdx; i < len(events); i++ { - // Events that will become immutable (fall outside future navigable range) have no indentation - if i < futureNavigableStartIdx { - fmt.Printf("%s\n", events[i].LogLine) // No indentation - } else { - // Add "Latest events" separator before first navigable event - if i == futureNavigableStartIdx { - color := ansi.Color(os.Stdout) - fmt.Printf("\n%s\n\n", color.Faint("Latest events (↑↓ to navigate)")) // Extra newline after separator - } - // Only indent selected event with ">", others have no indentation - if i == selectedIndex { - fmt.Printf("> %s\n", events[i].LogLine) // Selected - } else { - fmt.Printf("%s\n", events[i].LogLine) // No indentation - } - } - } - - // Blank line - fmt.Println() - - // Status message (will be replaced soon) - color := ansi.Color(os.Stdout) - statusMsg := fmt.Sprintf("%s Updating...", color.Faint("ā—")) - fmt.Printf("%s\n", statusMsg) - - // Re-enable raw mode - if ui.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } - } - - // Note: Event was already added to history at the start of this function - // (before the showingDetails check, so events are still tracked while viewing details) - - // Temporarily restore normal terminal mode for printing - if ui.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), ui.rawModeState) - } - - events := ui.eventHistory.GetEvents() - selectedIndex := ui.eventHistory.GetSelectedIndex() - - // Calculate the navigable window (last 10 events) - navigableStartIdx := len(events) - maxNavigableEvents - if navigableStartIdx < 0 { - navigableStartIdx = 0 - } - numNavigableEvents := len(events) - navigableStartIdx - - // If we have multiple navigable events and auto-selecting, redraw navigable window - // Also redraw if user has navigated (to show pinned selection) - if numNavigableEvents > 1 && !ui.eventHistory.IsUserNavigated() { - // Auto-selecting mode: redraw to move selection to latest - // Calculate total terminal lines occupied by previous navigable events - totalEventLines := 0 - for i := navigableStartIdx; i < len(events)-1; i++ { - totalEventLines += ui.calculateEventLines(events[i].LogLine) - } - linesToMoveUp := totalEventLines + 2 // previous event lines + blank + status - fmt.Printf("\033[%dA", linesToMoveUp) - fmt.Print("\033[J") - - // Print navigable events with selection on the latest - for i := navigableStartIdx; i < len(events); i++ { - if i == selectedIndex { - fmt.Printf("> %s\n", events[i].LogLine) - } else { - fmt.Printf("%s\n", events[i].LogLine) // No indentation - } - } - } else if ui.eventHistory.IsUserNavigated() && numNavigableEvents > 1 { - // User has navigated: redraw to show pinned selected event - // Get the navigable events (includes pinned selected event if applicable) - navigableIndices := ui.eventHistory.GetNavigableEvents() - - // Calculate total terminal lines occupied by previous navigable events - totalEventLines := 0 - for i := 0; i < len(navigableIndices)-1; i++ { - totalEventLines += ui.calculateEventLines(events[navigableIndices[i]].LogLine) - } - linesToMoveUp := totalEventLines + 2 // previous event lines + blank + status - fmt.Printf("\033[%dA", linesToMoveUp) - fmt.Print("\033[J") - - // Print navigable events (including pinned event) with selection indicator - for _, idx := range navigableIndices { - if idx == selectedIndex { - fmt.Printf("> %s\n", events[idx].LogLine) - } else { - fmt.Printf("%s\n", events[idx].LogLine) - } - } - } else { - // First event - simple append - if ui.statusLineShown { - if len(events) == 1 { - // First event - only clear the "waiting" status line - fmt.Print("\033[1A\033[2K\r") - } else { - // Clear status line and blank line - fmt.Print("\033[2A\033[2K\r\033[1B\033[2K\r\033[1A") - } - } - - // Print the new event - newEventIndex := len(events) - 1 - // Only indent if selected, otherwise no indentation - if selectedIndex == newEventIndex { - fmt.Printf("> %s\n", events[newEventIndex].LogLine) - } else { - fmt.Printf("%s\n", events[newEventIndex].LogLine) // No indentation - } - } - - // Blank line - fmt.Println() - - // Generate and print status message - statusMsg := ui.BuildStatusMessage(hasReceivedEvent) - fmt.Printf("%s\n", statusMsg) - ui.statusLineShown = true - - // Re-enable raw mode - if ui.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } -} - -// RedrawAfterDetailsView redraws the event list after returning from the details view -// less uses alternate screen, so the original screen content should be restored automatically -// We just need to redraw the events that may have arrived while viewing details -func (ui *TerminalUI) RedrawAfterDetailsView(hasReceivedEvent bool) { - ui.terminalMutex.Lock() - defer ui.terminalMutex.Unlock() - - // Temporarily restore normal terminal mode for printing - if ui.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), ui.rawModeState) - } - - // After less exits, the terminal should have restored the original screen - // We need to redraw the entire navigable events section since events may have arrived - - events := ui.eventHistory.GetEvents() - if len(events) == 0 { - // Re-enable raw mode - if ui.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } - return - } - - selectedIndex := ui.eventHistory.GetSelectedIndex() - - // Get the navigable events (includes pinned selected event if applicable) - navigableIndices := ui.eventHistory.GetNavigableEvents() - - // Calculate the normal navigable start for determining if we need separator - normalNavigableStartIdx := len(events) - maxNavigableEvents - if normalNavigableStartIdx < 0 { - normalNavigableStartIdx = 0 - } - - // Calculate how many lines to move up: navigable events + separator (if present) + blank + status - totalEventLines := 0 - for _, idx := range navigableIndices { - totalEventLines += ui.calculateEventLines(events[idx].LogLine) - } - linesToMoveUp := totalEventLines + 2 // event lines + blank + status - if normalNavigableStartIdx > 0 { - linesToMoveUp += 3 // blank + "Latest events" + blank - } - - // Move cursor up and clear everything below - fmt.Printf("\033[%dA", linesToMoveUp) - fmt.Print("\033[J") - - // Add separator if there are historical events - if normalNavigableStartIdx > 0 { - color := ansi.Color(os.Stdout) - fmt.Printf("\n%s\n\n", color.Faint("Latest events (↑↓ to navigate)")) - } - - // Print the navigable events with selection indicator - for _, idx := range navigableIndices { - if idx == selectedIndex { - fmt.Printf("> %s\n", events[idx].LogLine) // Selected event with > - } else { - fmt.Printf("%s\n", events[idx].LogLine) // No indentation - } - } - - // Add a newline before the status line - fmt.Println() - - // Generate and print the status message for the selected event - statusMsg := ui.BuildStatusMessage(hasReceivedEvent) - fmt.Printf("%s\n", statusMsg) - ui.statusLineShown = true - - // Re-enable raw mode - if ui.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } -} - -// RedrawEventsWithSelection updates the selection indicators without clearing the screen (only last 10 events) -func (ui *TerminalUI) RedrawEventsWithSelection(hasReceivedEvent bool) { - if ui.eventHistory.Count() == 0 { - return - } - - ui.terminalMutex.Lock() - defer ui.terminalMutex.Unlock() - - // Temporarily restore normal terminal mode for printing - if ui.rawModeState != nil { - term.Restore(int(os.Stdin.Fd()), ui.rawModeState) - } - - events := ui.eventHistory.GetEvents() - selectedIndex := ui.eventHistory.GetSelectedIndex() - - // Get the navigable events (includes pinned selected event if applicable) - navigableIndices := ui.eventHistory.GetNavigableEvents() - - // Calculate the normal navigable start for determining if we need separator - normalNavigableStartIdx := len(events) - maxNavigableEvents - if normalNavigableStartIdx < 0 { - normalNavigableStartIdx = 0 - } - - // Calculate total terminal lines occupied by navigable events - totalEventLines := 0 - for _, idx := range navigableIndices { - totalEventLines += ui.calculateEventLines(events[idx].LogLine) - } - - // Move cursor up to start of navigable events and clear everything below - linesToMoveUp := totalEventLines + 2 // event lines + blank + status - // If we have a separator, add 3 more lines (blank line + "Latest events" + blank line) - if normalNavigableStartIdx > 0 { - linesToMoveUp += 3 - } - fmt.Printf("\033[%dA", linesToMoveUp) - fmt.Print("\033[J") - - // NOTE: We NEVER redraw the "Events" title - it was printed once and stays permanent - - // Add separator if there are historical events - if normalNavigableStartIdx > 0 { - color := ansi.Color(os.Stdout) - fmt.Printf("\n%s\n\n", color.Faint("Latest events (↑↓ to navigate)")) // Extra newline after separator - } - - // Print the navigable events (including pinned event if applicable) with selection indicator - for _, idx := range navigableIndices { - if idx == selectedIndex { - fmt.Printf("> %s\n", events[idx].LogLine) // Selected event with > - } else { - fmt.Printf("%s\n", events[idx].LogLine) // No indentation - } - } - - // Add a newline before the status line - fmt.Println() - - // Generate and print the status message for the selected event - statusMsg := ui.BuildStatusMessage(hasReceivedEvent) - fmt.Printf("%s\n", statusMsg) - ui.statusLineShown = true - - // Re-enable raw mode - if ui.rawModeState != nil { - term.MakeRaw(int(os.Stdin.Fd())) - } -} diff --git a/pkg/tui/model.go b/pkg/tui/model.go new file mode 100644 index 0000000..fef4018 --- /dev/null +++ b/pkg/tui/model.go @@ -0,0 +1,401 @@ +package tui + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + "time" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" + + "github.com/hookdeck/hookdeck-cli/pkg/websocket" +) + +const ( + maxEvents = 1000 // Maximum events to keep in memory (all navigable) + timeLayout = "2006-01-02 15:04:05" // Time format for display +) + +// EventInfo represents a single event with all its data +type EventInfo struct { + ID string // Event ID from Hookdeck + AttemptID string // Attempt ID (unique per retry) + Status int + Success bool + Time time.Time + Data *websocket.Attempt + LogLine string + ResponseStatus int + ResponseHeaders map[string][]string + ResponseBody string + ResponseDuration time.Duration +} + +// Model is the Bubble Tea model for the interactive TUI +type Model struct { + // Configuration + cfg *Config + + // Event history + events []EventInfo + selectedIndex int + userNavigated bool // Track if user has manually navigated away from latest + + // UI state + ready bool + hasReceivedEvent bool + isConnected bool + waitingFrameToggle bool + width int + height int + viewport viewport.Model + viewportReady bool + headerHeight int // Height of the fixed header + + // Details view state + showingDetails bool + detailsViewport viewport.Model + detailsContent string + eventsTitleShown bool // Track if "Events" title has been displayed + + // Header state + headerCollapsed bool // Track if connection header is collapsed +} + +// Config holds configuration for the TUI +type Config struct { + DeviceName string + APIKey string + APIBaseURL string + DashboardBaseURL string + ConsoleBaseURL string + ProjectMode string + ProjectID string + GuestURL string + TargetURL *url.URL + Sources []*hookdecksdk.Source + Connections []*hookdecksdk.Connection +} + +// NewModel creates a new TUI model +func NewModel(cfg *Config) Model { + return Model{ + cfg: cfg, + events: make([]EventInfo, 0), + selectedIndex: -1, + ready: false, + isConnected: false, + } +} + +// Init initializes the model (required by Bubble Tea) +func (m Model) Init() tea.Cmd { + return tea.Batch( + tickWaitingAnimation(), + ) +} + +// AddEvent adds a new event to the history +func (m *Model) AddEvent(event EventInfo) { + // Check for duplicates (same ID and timestamp) + for i := len(m.events) - 1; i >= 0; i-- { + if m.events[i].ID == event.ID && m.events[i].Time.Equal(event.Time) { + return // Duplicate, skip + } + } + + // Record if user is on the current latest before adding new event + wasOnLatest := m.selectedIndex == len(m.events)-1 + + // Add event + m.events = append(m.events, event) + + // Trim to maxEvents if exceeded - old events just disappear + if len(m.events) > maxEvents { + removeCount := len(m.events) - maxEvents + m.events = m.events[removeCount:] + + // Adjust selected index + if m.selectedIndex >= 0 { + m.selectedIndex -= removeCount + if m.selectedIndex < 0 { + // Selected event was removed, select latest + m.selectedIndex = len(m.events) - 1 + m.userNavigated = false + } + } + } + + // If user was on the latest event when new event arrived, resume auto-tracking + if m.userNavigated && wasOnLatest { + m.userNavigated = false + } + + // Auto-select latest unless user has manually navigated + if !m.userNavigated { + m.selectedIndex = len(m.events) - 1 + // Note: viewport will be scrolled in View() after content is updated + } + + // Mark as having received first event and auto-collapse header + if !m.hasReceivedEvent { + m.hasReceivedEvent = true + m.headerCollapsed = true // Auto-collapse on first event + } +} + +// UpdateEvent updates an existing event by EventID + Time +func (m *Model) UpdateEvent(update UpdateEventMsg) { + // Find event by EventID + Time (unique identifier for retries) + for i := range m.events { + if m.events[i].ID == update.EventID && m.events[i].Time.Equal(update.Time) { + // Update event fields + m.events[i].Status = update.Status + m.events[i].Success = update.Success + m.events[i].LogLine = update.LogLine + m.events[i].ResponseStatus = update.ResponseStatus + m.events[i].ResponseHeaders = update.ResponseHeaders + m.events[i].ResponseBody = update.ResponseBody + m.events[i].ResponseDuration = update.ResponseDuration + return + } + } +} + +// Navigate moves selection up or down (all events are navigable) +func (m *Model) Navigate(direction int) bool { + if len(m.events) == 0 { + return false + } + + // Ensure selected index is valid + if m.selectedIndex < 0 || m.selectedIndex >= len(m.events) { + m.selectedIndex = len(m.events) - 1 + m.userNavigated = false + return false + } + + // Calculate new position + newIndex := m.selectedIndex + direction + + // Clamp to valid range + if newIndex < 0 { + newIndex = 0 + } else if newIndex >= len(m.events) { + newIndex = len(m.events) - 1 + } + + if newIndex != m.selectedIndex { + m.selectedIndex = newIndex + m.userNavigated = true + + // Don't reset userNavigated here to avoid jump when navigating to latest + // It will be reset in AddEvent() when a new event arrives while on latest + + // Auto-scroll viewport to keep selected event visible + m.scrollToSelectedEvent() + + return true + } + + return false +} + +// scrollToSelectedEvent scrolls the viewport to keep the selected event visible +func (m *Model) scrollToSelectedEvent() { + if !m.viewportReady || m.selectedIndex < 0 { + return + } + + // Each event is one line, selected event is at line m.selectedIndex + // Add 1 to account for the leading newline in renderEventHistory + lineNum := m.selectedIndex + 1 + + // Scroll to make this line visible + if lineNum < m.viewport.YOffset { + // Selected is above visible area, scroll up + m.viewport.YOffset = lineNum + } else if lineNum >= m.viewport.YOffset+m.viewport.Height { + // Selected is below visible area, scroll down + m.viewport.YOffset = lineNum - m.viewport.Height + 1 + } + + // Clamp offset + if m.viewport.YOffset < 0 { + m.viewport.YOffset = 0 + } +} + +// GetSelectedEvent returns the currently selected event +func (m *Model) GetSelectedEvent() *EventInfo { + if len(m.events) == 0 { + return nil + } + + if m.selectedIndex < 0 || m.selectedIndex >= len(m.events) { + m.selectedIndex = len(m.events) - 1 + m.userNavigated = false + } + + return &m.events[m.selectedIndex] +} + +// calculateHeaderHeight counts the number of lines in the header +func (m *Model) calculateHeaderHeight(header string) int { + return strings.Count(header, "\n") + 1 +} + +// buildDetailsContent builds the formatted details view for an event +func (m *Model) buildDetailsContent(event *EventInfo) string { + var content strings.Builder + + content.WriteString("═══════════════════════════════════════════════════════════════\n") + content.WriteString(" EVENT DETAILS\n") + content.WriteString("═══════════════════════════════════════════════════════════════\n\n") + + // Event metadata + content.WriteString(faintStyle.Render("Event ID: ") + event.ID + "\n") + content.WriteString(faintStyle.Render("Time: ") + event.Time.Format(timeLayout) + "\n") + if event.ResponseDuration > 0 { + content.WriteString(faintStyle.Render("Duration: ") + event.ResponseDuration.String() + "\n") + } + content.WriteString("\n") + + // Request section + if event.Data != nil { + content.WriteString("─────────────────────────────────────────────────────────────\n") + content.WriteString(" REQUEST\n") + content.WriteString("─────────────────────────────────────────────────────────────\n\n") + + content.WriteString(faintStyle.Render("Method: ") + event.Data.Body.Request.Method + "\n") + content.WriteString(faintStyle.Render("Path: ") + event.Data.Body.Path + "\n\n") + + // Request headers + content.WriteString("Headers:\n") + if len(event.Data.Body.Request.Headers) > 0 { + // Parse headers JSON + var headers map[string]string + if err := json.Unmarshal(event.Data.Body.Request.Headers, &headers); err == nil { + for key, value := range headers { + content.WriteString(" " + faintStyle.Render(key+":") + " " + value + "\n") + } + } else { + content.WriteString(" " + string(event.Data.Body.Request.Headers) + "\n") + } + } else { + content.WriteString(" " + faintStyle.Render("(none)") + "\n") + } + content.WriteString("\n") + + // Request body + content.WriteString("Body:\n") + if event.Data.Body.Request.DataString != "" { + // Try to pretty print JSON + prettyBody := m.prettyPrintJSON(event.Data.Body.Request.DataString) + content.WriteString(prettyBody + "\n") + } else { + content.WriteString(" " + faintStyle.Render("(empty)") + "\n") + } + content.WriteString("\n") + } + + // Response section + content.WriteString("─────────────────────────────────────────────────────────────\n") + content.WriteString(" RESPONSE\n") + content.WriteString("─────────────────────────────────────────────────────────────\n\n") + + if event.ResponseStatus > 0 { + content.WriteString(faintStyle.Render("Status: ") + fmt.Sprintf("%d", event.ResponseStatus) + "\n\n") + + // Response headers + content.WriteString("Headers:\n") + if len(event.ResponseHeaders) > 0 { + for key, values := range event.ResponseHeaders { + for _, value := range values { + content.WriteString(" " + faintStyle.Render(key+":") + " " + value + "\n") + } + } + } else { + content.WriteString(" " + faintStyle.Render("(none)") + "\n") + } + content.WriteString("\n") + + // Response body + content.WriteString("Body:\n") + if event.ResponseBody != "" { + // Try to pretty print JSON + prettyBody := m.prettyPrintJSON(event.ResponseBody) + content.WriteString(prettyBody + "\n") + } else { + content.WriteString(" " + faintStyle.Render("(empty)") + "\n") + } + } else { + content.WriteString(faintStyle.Render("(No response received yet)") + "\n") + } + + content.WriteString("\n") + content.WriteString("═══════════════════════════════════════════════════════════════\n") + content.WriteString("Press " + boldStyle.Render("[d]") + " or " + boldStyle.Render("[ESC]") + " to close • " + boldStyle.Render("[↑↓]") + " to scroll\n") + content.WriteString("═══════════════════════════════════════════════════════════════\n") + + return content.String() +} + +// prettyPrintJSON attempts to pretty print JSON, returns original if not valid JSON +func (m *Model) prettyPrintJSON(input string) string { + var obj interface{} + if err := json.Unmarshal([]byte(input), &obj); err != nil { + // Not valid JSON, return original + return input + } + + // Pretty print with 2-space indentation + pretty, err := json.MarshalIndent(obj, "", " ") + if err != nil { + // Fallback to original + return input + } + + return string(pretty) +} + +// Messages for Bubble Tea + +// NewEventMsg is sent when a new webhook event arrives +type NewEventMsg struct { + Event EventInfo +} + +// UpdateEventMsg is sent when an existing event gets a response +type UpdateEventMsg struct { + EventID string // Event ID + Time time.Time // Time when event was received (unique with EventID) + Status int + Success bool + LogLine string + ResponseStatus int + ResponseHeaders map[string][]string + ResponseBody string + ResponseDuration time.Duration +} + +// ConnectingMsg is sent when starting to connect +type ConnectingMsg struct{} + +// ConnectedMsg is sent when websocket connects +type ConnectedMsg struct{} + +// DisconnectedMsg is sent when websocket disconnects +type DisconnectedMsg struct{} + +// TickWaitingMsg is sent to animate waiting indicator +type TickWaitingMsg struct{} + +func tickWaitingAnimation() tea.Cmd { + return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { + return TickWaitingMsg{} + }) +} diff --git a/pkg/tui/styles.go b/pkg/tui/styles.go new file mode 100644 index 0000000..a62207e --- /dev/null +++ b/pkg/tui/styles.go @@ -0,0 +1,84 @@ +package tui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +var ( + // Color definitions matching current implementation + colorGreen = lipgloss.Color("2") // Green for success + colorRed = lipgloss.Color("1") // Red for errors + colorYellow = lipgloss.Color("3") // Yellow for warnings + colorFaint = lipgloss.Color("240") // Faint gray + colorPurple = lipgloss.Color("5") // Purple for brand accent + colorCyan = lipgloss.Color("6") // Cyan for brand accent + + // Base styles + faintStyle = lipgloss.NewStyle(). + Foreground(colorFaint) + + boldStyle = lipgloss.NewStyle(). + Bold(true) + + greenStyle = lipgloss.NewStyle(). + Foreground(colorGreen) + + redStyle = lipgloss.NewStyle(). + Foreground(colorRed). + Bold(true) + + yellowStyle = lipgloss.NewStyle(). + Foreground(colorYellow) + + // Brand styles + brandStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("4")). // Blue + Bold(true) + + brandAccentStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("4")) // Blue + + // Component styles + selectionIndicatorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("7")) // White/default + + sectionTitleStyle = faintStyle.Copy() + + statusBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("7")) + + waitingDotStyle = greenStyle.Copy() + + connectingDotStyle = yellowStyle.Copy() + + dividerStyle = lipgloss.NewStyle(). + Foreground(colorFaint) + + // Status code color styles + successStatusStyle = lipgloss.NewStyle(). + Foreground(colorGreen) + + errorStatusStyle = lipgloss.NewStyle(). + Foreground(colorRed) + + warningStatusStyle = lipgloss.NewStyle(). + Foreground(colorYellow) +) + +// ColorizeStatus returns a styled status code string +func ColorizeStatus(status int) string { + statusStr := fmt.Sprintf("%d", status) + + switch { + case status >= 200 && status < 300: + return successStatusStyle.Render(statusStr) + case status >= 400: + return errorStatusStyle.Render(statusStr) + case status >= 300: + return warningStatusStyle.Render(statusStr) + default: + return statusStr + } +} diff --git a/pkg/tui/update.go b/pkg/tui/update.go new file mode 100644 index 0000000..9dc8687 --- /dev/null +++ b/pkg/tui/update.go @@ -0,0 +1,262 @@ +package tui + +import ( + "context" + "fmt" + "net/url" + "os/exec" + "runtime" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// Update handles all events in the Bubble Tea event loop +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.KeyMsg: + return m.handleKeyPress(msg) + + case tea.MouseMsg: + // Ignore all mouse events (including scroll) + // Navigation should only work with arrow keys + return m, nil + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + if !m.viewportReady { + // Initialize viewport on first window size message + // Reserve space for header (will be calculated dynamically) and status bar (3 lines) + m.viewport = viewport.New(msg.Width, msg.Height-15) // Initial estimate + m.viewportReady = true + m.ready = true + } else { + // Update viewport dimensions + m.viewport.Width = msg.Width + // Height will be set properly in the View function + } + return m, nil + + case NewEventMsg: + m.AddEvent(msg.Event) + return m, nil + + case UpdateEventMsg: + m.UpdateEvent(msg) + return m, nil + + case ConnectingMsg: + m.isConnected = false + return m, nil + + case ConnectedMsg: + m.isConnected = true + return m, nil + + case DisconnectedMsg: + m.isConnected = false + return m, nil + + case TickWaitingMsg: + // Toggle waiting animation + if !m.hasReceivedEvent { + m.waitingFrameToggle = !m.waitingFrameToggle + return m, tickWaitingAnimation() + } + return m, nil + + case retryResultMsg: + // Retry completed, could show notification if needed + return m, nil + + case openBrowserResultMsg: + // Browser opened, could show notification if needed + return m, nil + } + + return m, nil +} + +// handleKeyPress processes keyboard input +func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Always allow quit and header toggle + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "i", "I": + // Toggle header collapsed/expanded + m.headerCollapsed = !m.headerCollapsed + return m, nil + } + + // Disable other shortcuts until connected and first event received + if !m.isConnected || !m.hasReceivedEvent { + return m, nil + } + + // Handle navigation and actions + switch msg.String() { + case "up", "k": + if m.showingDetails { + // Scroll details view up + m.detailsViewport.LineUp(1) + return m, nil + } + if m.Navigate(-1) { + return m, nil + } + + case "down", "j": + if m.showingDetails { + // Scroll details view down + m.detailsViewport.LineDown(1) + return m, nil + } + if m.Navigate(1) { + return m, nil + } + + case "pgup": + if m.showingDetails { + m.detailsViewport.ViewUp() + return m, nil + } + + case "pgdown": + if m.showingDetails { + m.detailsViewport.ViewDown() + return m, nil + } + + case "r", "R": + // Retry selected event + return m, m.retrySelectedEvent() + + case "o", "O": + // Open event in browser + return m, m.openSelectedEventInBrowser() + + case "d", "D": + // Toggle event details view + if m.showingDetails { + // Close details view + m.showingDetails = false + } else { + // Open details view + selectedEvent := m.GetSelectedEvent() + if selectedEvent != nil { + m.detailsContent = m.buildDetailsContent(selectedEvent) + m.showingDetails = true + + // Initialize details viewport if not already done + m.detailsViewport = viewport.New(m.width, m.height) + m.detailsViewport.SetContent(m.detailsContent) + m.detailsViewport.GotoTop() + } + } + return m, nil + + case "esc": + // Close details view + if m.showingDetails { + m.showingDetails = false + return m, nil + } + } + + return m, nil +} + +// retrySelectedEvent retries the currently selected event +func (m Model) retrySelectedEvent() tea.Cmd { + selectedEvent := m.GetSelectedEvent() + if selectedEvent == nil || selectedEvent.ID == "" { + return nil + } + + eventID := selectedEvent.ID + apiKey := m.cfg.APIKey + apiBaseURL := m.cfg.APIBaseURL + projectID := m.cfg.ProjectID + + return func() tea.Msg { + // Create HTTP client + parsedBaseURL, err := url.Parse(apiBaseURL) + if err != nil { + return retryResultMsg{err: err} + } + + client := &hookdeck.Client{ + BaseURL: parsedBaseURL, + APIKey: apiKey, + ProjectID: projectID, + } + + // Make retry request + retryURL := fmt.Sprintf("/events/%s/retry", eventID) + resp, err := client.Post(context.Background(), retryURL, []byte("{}"), nil) + if err != nil { + return retryResultMsg{err: err} + } + defer resp.Body.Close() + + return retryResultMsg{success: true} + } +} + +// openSelectedEventInBrowser opens the event in the dashboard +func (m Model) openSelectedEventInBrowser() tea.Cmd { + selectedEvent := m.GetSelectedEvent() + if selectedEvent == nil || selectedEvent.ID == "" { + return nil + } + + return func() tea.Msg { + // Build event URL with team_id query parameter + var eventURL string + if m.cfg.ProjectMode == "console" { + eventURL = m.cfg.ConsoleBaseURL + "/?event_id=" + selectedEvent.ID + "&team_id=" + m.cfg.ProjectID + } else { + eventURL = m.cfg.DashboardBaseURL + "/events/" + selectedEvent.ID + "?team_id=" + m.cfg.ProjectID + } + + // Open in browser + err := openBrowser(eventURL) + return openBrowserResultMsg{err: err} + } +} + +// openBrowser opens a URL in the default browser (cross-platform) +func openBrowser(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start", url} + case "darwin": + cmd = "open" + args = []string{url} + default: // "linux", "freebsd", "openbsd", "netbsd" + cmd = "xdg-open" + args = []string{url} + } + + return exec.Command(cmd, args...).Start() +} + +// Result messages + +type retryResultMsg struct { + success bool + err error +} + +type openBrowserResultMsg struct { + err error +} diff --git a/pkg/tui/view.go b/pkg/tui/view.go new file mode 100644 index 0000000..e706b33 --- /dev/null +++ b/pkg/tui/view.go @@ -0,0 +1,443 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// View renders the TUI with fixed header and scrollable event list +func (m Model) View() string { + if !m.ready || !m.viewportReady { + return "" + } + + // If showing details, render full-screen details view + if m.showingDetails { + return m.detailsViewport.View() + } + + // Build fixed header (connection info + events title + divider) + var header strings.Builder + header.WriteString(m.renderConnectionInfo()) + header.WriteString("\n") + + // Add events title with divider + eventsTitle := "Events • [↑↓] Navigate " + titleLen := len(eventsTitle) + remainingWidth := m.width - titleLen + if remainingWidth < 0 { + remainingWidth = 0 + } + dividerLine := strings.Repeat("─", remainingWidth) + header.WriteString(faintStyle.Render(eventsTitle + dividerLine)) + header.WriteString("\n") + + headerStr := header.String() + headerHeight := m.calculateHeaderHeight(headerStr) + + // Build scrollable content for viewport + var content strings.Builder + + // If not connected yet, show connecting status + if !m.isConnected { + content.WriteString("\n") + content.WriteString(m.renderConnectingStatus()) + content.WriteString("\n") + } else if !m.hasReceivedEvent { + // If no events received yet, show waiting animation + content.WriteString("\n") + content.WriteString(m.renderWaitingStatus()) + content.WriteString("\n") + } else { + // Add newline before event history (part of scrollable content) + content.WriteString("\n") + // Render event history + content.WriteString(m.renderEventHistory()) + } + + // Update viewport content + m.viewport.SetContent(content.String()) + + // Calculate exact viewport height + // m.height is total LINES on screen + // We need: header lines + viewport lines + divider (1) + status (1) = m.height + + var viewportHeight int + if m.hasReceivedEvent { + // Total lines: header + viewport + divider + status + viewportHeight = m.height - headerHeight - 2 + } else { + // Total lines: header + viewport + viewportHeight = m.height - headerHeight + } + + if viewportHeight < 1 { + viewportHeight = 1 + } + m.viewport.Height = viewportHeight + + // Auto-scroll to bottom if tracking latest event + if !m.userNavigated && len(m.events) > 0 { + m.viewport.GotoBottom() + } + + // Build output with exact line control + output := headerStr // Header with its newlines + + // Viewport renders exactly viewportHeight lines + viewportOutput := m.viewport.View() + output += viewportOutput + + if m.hasReceivedEvent { + // Ensure we have a newline before divider if viewport doesn't end with one + if !strings.HasSuffix(viewportOutput, "\n") { + output += "\n" + } + + // Divider line + divider := strings.Repeat("─", m.width) + output += dividerStyle.Render(divider) + "\n" + + // Status bar - LAST line, no trailing newline + output += m.renderStatusBar() + } else { + // Remove any trailing newline if no status bar + output = strings.TrimSuffix(output, "\n") + } + + return output +} + +// renderConnectingStatus shows the connecting animation +func (m Model) renderConnectingStatus() string { + dot := "ā—" + if m.waitingFrameToggle { + dot = "ā—‹" + } + + return connectingDotStyle.Render(dot) + " Connecting..." +} + +// renderWaitingStatus shows the waiting animation before first event +func (m Model) renderWaitingStatus() string { + dot := "ā—" + if m.waitingFrameToggle { + dot = "ā—‹" + } + + return waitingDotStyle.Render(dot) + " Connected. Waiting for events..." +} + +// renderEventHistory renders all events with selection indicator on selected +func (m Model) renderEventHistory() string { + if len(m.events) == 0 { + return "" + } + + var s strings.Builder + + // Render all events with selection indicator + for i, event := range m.events { + if i == m.selectedIndex { + // Selected event - show with ">" prefix + s.WriteString(selectionIndicatorStyle.Render("> ")) + s.WriteString(event.LogLine) + } else { + // Non-selected event - no prefix + s.WriteString(event.LogLine) + } + s.WriteString("\n") + } + + return s.String() +} + +// renderStatusBar renders the bottom status bar with keyboard shortcuts +func (m Model) renderStatusBar() string { + selectedEvent := m.GetSelectedEvent() + if selectedEvent == nil { + return "" + } + + // Determine width-based verbosity + isNarrow := m.width < 100 + isVeryNarrow := m.width < 60 + + // Build status message + var statusMsg string + eventType := "Last event" + if m.userNavigated { + eventType = "Selected event" + } + + if selectedEvent.Success { + // Success status + checkmark := greenStyle.Render("āœ“") + if isVeryNarrow { + statusMsg = fmt.Sprintf("> %s %s [%d]", checkmark, eventType, selectedEvent.Status) + } else if isNarrow { + statusMsg = fmt.Sprintf("> %s %s succeeded [%d] | [r] [o] [d] [q]", + checkmark, eventType, selectedEvent.Status) + } else { + statusMsg = fmt.Sprintf("> %s %s succeeded with status %d | [r] Retry • [o] Open in dashboard • [d] Show data", + checkmark, eventType, selectedEvent.Status) + } + } else { + // Error status + xmark := redStyle.Render("x") + statusText := "failed" + if selectedEvent.Status == 0 { + statusText = "failed with error" + } else { + statusText = fmt.Sprintf("failed with status %d", selectedEvent.Status) + } + + if isVeryNarrow { + if selectedEvent.Status == 0 { + statusMsg = fmt.Sprintf("> %s %s [ERR]", xmark, eventType) + } else { + statusMsg = fmt.Sprintf("> %s %s [%d]", xmark, eventType, selectedEvent.Status) + } + } else if isNarrow { + if selectedEvent.Status == 0 { + statusMsg = fmt.Sprintf("> %s %s failed | [r] [o] [d] [q]", + xmark, eventType) + } else { + statusMsg = fmt.Sprintf("> %s %s failed [%d] | [r] [o] [d] [q]", + xmark, eventType, selectedEvent.Status) + } + } else { + statusMsg = fmt.Sprintf("> %s %s %s | [r] Retry • [o] Open in dashboard • [d] Show event data", + xmark, eventType, statusText) + } + } + + return statusBarStyle.Render(statusMsg) +} + +// FormatEventLog formats an event into a log line matching the current style +func FormatEventLog(event EventInfo, dashboardURL, consoleURL, projectMode string) string { + localTime := event.Time.Format(timeLayout) + + // Build event URL + var url string + if projectMode == "console" { + url = consoleURL + "/?event_id=" + event.ID + } else { + url = dashboardURL + "/events/" + event.ID + } + + // Format based on whether request failed or succeeded + if event.ResponseStatus == 0 && !event.Success { + // Request failed completely (no response) + return fmt.Sprintf("%s [%s] Failed to %s: network error", + faintStyle.Render(localTime), + redStyle.Render("ERROR"), + event.Data.Body.Request.Method, + ) + } + + // Format normal response + durationMs := event.ResponseDuration.Milliseconds() + requestURL := fmt.Sprintf("http://localhost%s", event.Data.Body.Path) // Simplified for now + + return fmt.Sprintf("%s [%s] %s %s %s %s %s", + faintStyle.Render(localTime), + ColorizeStatus(event.ResponseStatus), + event.Data.Body.Request.Method, + requestURL, + faintStyle.Render(fmt.Sprintf("(%dms)", durationMs)), + faintStyle.Render("→"), + faintStyle.Render(url), + ) +} + +// renderConnectionInfo renders the sources and connections header +func (m Model) renderConnectionInfo() string { + // If header is collapsed, show compact view + if m.headerCollapsed { + return m.renderCompactHeader() + } + + var s strings.Builder + + // Brand header + s.WriteString(m.renderBrandHeader()) + s.WriteString("\n\n") + + // Title with source/connection count and collapse hint + numSources := 0 + numConnections := 0 + if m.cfg.Sources != nil { + numSources = len(m.cfg.Sources) + } + if m.cfg.Connections != nil { + numConnections = len(m.cfg.Connections) + } + + sourcesText := fmt.Sprintf("%d source", numSources) + if numSources != 1 { + sourcesText += "s" + } + connectionsText := fmt.Sprintf("%d connection", numConnections) + if numConnections != 1 { + connectionsText += "s" + } + + listeningTitle := fmt.Sprintf("Listening on %s • %s • [i] Collapse", sourcesText, connectionsText) + s.WriteString(faintStyle.Render(listeningTitle)) + s.WriteString("\n\n") + + // Group connections by source + sourceConnections := make(map[string][]*struct { + connection *interface{} + destName string + cliPath string + }) + + if m.cfg.Sources != nil && m.cfg.Connections != nil { + for _, conn := range m.cfg.Connections { + sourceID := conn.Source.Id + destName := "" + cliPath := "" + + if conn.FullName != nil { + parts := strings.Split(*conn.FullName, "->") + if len(parts) == 2 { + destName = strings.TrimSpace(parts[1]) + } + } + + if conn.Destination.CliPath != nil { + cliPath = *conn.Destination.CliPath + } + + if sourceConnections[sourceID] == nil { + sourceConnections[sourceID] = make([]*struct { + connection *interface{} + destName string + cliPath string + }, 0) + } + + sourceConnections[sourceID] = append(sourceConnections[sourceID], &struct { + connection *interface{} + destName string + cliPath string + }{nil, destName, cliPath}) + } + + // Render each source + for i, source := range m.cfg.Sources { + s.WriteString(boldStyle.Render(source.Name)) + s.WriteString("\n") + + // Show webhook URL + s.WriteString("│ Requests to → ") + s.WriteString(source.Url) + s.WriteString("\n") + + // Show connections + if conns, exists := sourceConnections[source.Id]; exists { + numConns := len(conns) + for j, conn := range conns { + fullPath := m.cfg.TargetURL.Scheme + "://" + m.cfg.TargetURL.Host + conn.cliPath + + connDisplay := "" + if conn.destName != "" { + connDisplay = " " + faintStyle.Render(fmt.Sprintf("(%s)", conn.destName)) + } + + if j == numConns-1 { + s.WriteString("└─ Forwards to → ") + } else { + s.WriteString("ā”œā”€ Forwards to → ") + } + s.WriteString(fullPath) + s.WriteString(connDisplay) + s.WriteString("\n") + } + } + + // Add spacing between sources + if i < len(m.cfg.Sources)-1 { + s.WriteString("\n") + } + } + } + + // Dashboard/guest URL hint + s.WriteString("\n") + if m.cfg.GuestURL != "" { + s.WriteString("šŸ’” Sign up to make your webhook URL permanent: ") + s.WriteString(m.cfg.GuestURL) + } else { + // Build URL with team_id query parameter + var displayURL string + if m.cfg.ProjectMode == "console" { + displayURL = m.cfg.ConsoleBaseURL + "?team_id=" + m.cfg.ProjectID + } else { + displayURL = m.cfg.DashboardBaseURL + "/events/cli?team_id=" + m.cfg.ProjectID + } + s.WriteString("šŸ’” View dashboard to inspect, retry & bookmark events: ") + s.WriteString(displayURL) + } + s.WriteString("\n") + + return s.String() +} + +// renderBrandHeader renders the Hookdeck CLI brand header +func (m Model) renderBrandHeader() string { + // Connection visual with brand name + leftLine := brandAccentStyle.Render("ā—ā”€ā”€") + rightLine := brandAccentStyle.Render("ā”€ā”€ā—") + brandName := brandStyle.Render(" HOOKDECK CLI ") + return leftLine + brandName + rightLine +} + +// renderCompactHeader renders a collapsed/compact version of the connection header +func (m Model) renderCompactHeader() string { + var s strings.Builder + + // Brand header + s.WriteString(m.renderBrandHeader()) + s.WriteString("\n\n") + + // Count sources and connections + numSources := 0 + numConnections := 0 + if m.cfg.Sources != nil { + numSources = len(m.cfg.Sources) + } + if m.cfg.Connections != nil { + numConnections = len(m.cfg.Connections) + } + + // Compact summary with toggle hint + sourcesText := fmt.Sprintf("%d source", numSources) + if numSources != 1 { + sourcesText += "s" + } + connectionsText := fmt.Sprintf("%d connection", numConnections) + if numConnections != 1 { + connectionsText += "s" + } + + summary := fmt.Sprintf("Listening on %s • %s • [i] Expand", + sourcesText, + connectionsText) + s.WriteString(faintStyle.Render(summary)) + s.WriteString("\n") + + return s.String() +} + +// Utility function to strip ANSI codes for length calculation (if needed) +func stripANSI(s string) string { + // Lipgloss handles this internally, but we can provide a simple implementation + // For now, we'll use the string as-is since Lipgloss manages rendering + return lipgloss.NewStyle().Render(s) +} From 6c25089cec462135e0a1b7c008e8ecd2554f0983 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Sun, 12 Oct 2025 23:01:50 -0400 Subject: [PATCH 21/22] chore: Refactor renderer and proxy --- pkg/listen/listen.go | 36 +- pkg/listen/printer.go | 9 - pkg/proxy/proxy.go | 221 +++++---- pkg/proxy/proxy_tui.go | 801 ------------------------------ pkg/proxy/renderer.go | 71 +++ pkg/proxy/renderer_interactive.go | 229 +++++++++ pkg/proxy/renderer_simple.go | 156 ++++++ pkg/tui/model.go | 33 +- pkg/tui/update.go | 4 +- 9 files changed, 648 insertions(+), 912 deletions(-) delete mode 100644 pkg/proxy/proxy_tui.go create mode 100644 pkg/proxy/renderer.go create mode 100644 pkg/proxy/renderer_interactive.go create mode 100644 pkg/proxy/renderer_simple.go diff --git a/pkg/listen/listen.go b/pkg/listen/listen.go index 181f134..60edd89 100644 --- a/pkg/listen/listen.go +++ b/pkg/listen/listen.go @@ -20,10 +20,10 @@ import ( "errors" "fmt" "net/url" + "os" "regexp" "strings" - "github.com/hookdeck/hookdeck-cli/pkg/ansi" "github.com/hookdeck/hookdeck-cli/pkg/config" "github.com/hookdeck/hookdeck-cli/pkg/login" "github.com/hookdeck/hookdeck-cli/pkg/proxy" @@ -126,17 +126,14 @@ Specify a single destination to update the path. For example, pass a connection // Start proxy // For non-interactive modes, print connection info before starting if flags.Output == "compact" || flags.Output == "quiet" { - printListenMessage(config, isMultiSource) fmt.Println() printSourcesWithConnections(config, sources, connections, URL, guestURL) fmt.Println() - fmt.Printf("%s\n", ansi.Faint("Events")) - fmt.Println() } // For interactive mode, connection info will be shown in TUI - // Use new TUI-based proxy - p := proxy.NewTUI(&proxy.Config{ + // Create proxy config + proxyCfg := &proxy.Config{ DeviceName: config.DeviceName, Key: config.Profile.APIKey, ProjectID: config.Profile.ProjectId, @@ -152,11 +149,34 @@ Specify a single destination to update the path. For example, pass a connection Output: flags.Output, GuestURL: guestURL, MaxConnections: flags.MaxConnections, - }, sources, connections) + } + + // Create renderer based on output mode + rendererCfg := &proxy.RendererConfig{ + DeviceName: config.DeviceName, + APIKey: config.Profile.APIKey, + APIBaseURL: config.APIBaseURL, + DashboardBaseURL: config.DashboardBaseURL, + ConsoleBaseURL: config.ConsoleBaseURL, + ProjectMode: config.Profile.ProjectMode, + ProjectID: config.Profile.ProjectId, + GuestURL: guestURL, + TargetURL: URL, + Output: flags.Output, + Sources: sources, + Connections: connections, + } + + renderer := proxy.NewRenderer(rendererCfg) + + // Create and run proxy with renderer + p := proxy.New(proxyCfg, connections, renderer) err = p.Run(context.Background()) if err != nil { - return err + // Renderer is already cleaned up, safe to print error + fmt.Fprintf(os.Stderr, "\n%s\n", err) + os.Exit(1) } return nil diff --git a/pkg/listen/printer.go b/pkg/listen/printer.go index 4664c3a..466198f 100644 --- a/pkg/listen/printer.go +++ b/pkg/listen/printer.go @@ -10,15 +10,6 @@ import ( hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" ) -func printListenMessage(config *config.Config, isMultiSource bool) { - if !isMultiSource { - return - } - - fmt.Println() - fmt.Println("Listening for events on Sources that have Connections with CLI Destinations") -} - func printSourcesWithConnections(config *config.Config, sources []*hookdecksdk.Source, connections []*hookdecksdk.Connection, targetURL *url.URL, guestURL string) { // Group connections by source ID sourceConnections := make(map[string][]*hookdecksdk.Connection) diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 9cfe899..d2ad190 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -20,7 +20,6 @@ import ( log "github.com/sirupsen/logrus" - "github.com/hookdeck/hookdeck-cli/pkg/ansi" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "github.com/hookdeck/hookdeck-cli/pkg/websocket" hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" @@ -68,7 +67,8 @@ type Proxy struct { httpClient *http.Client transport *http.Transport activeRequests int32 - maxConnWarned bool // Track if we've warned about connection limit + maxConnWarned bool // Track if we've warned about connection limit + renderer Renderer } func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { @@ -90,7 +90,7 @@ func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context { // - Create a new CLI session // - Create a new websocket connection func (p *Proxy) Run(parentCtx context.Context) error { - const maxConnectAttempts = 3 + const maxConnectAttempts = 10 nAttempts := 0 // Track whether or not we have connected successfully. @@ -113,17 +113,20 @@ func (p *Proxy) Run(parentCtx context.Context) error { }).Debug("Ctrl+C received, cleaning up...") }) - s := ansi.StartNewSpinner("Getting ready...", p.cfg.Log.Out) + // Notify renderer we're connecting + p.renderer.OnConnecting() session, err := p.createSession(signalCtx) if err != nil { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - p.cfg.Log.Fatalf("Error while authenticating with Hookdeck: %v", err) + p.renderer.OnError(err) + p.renderer.Cleanup() + return fmt.Errorf("error while authenticating with Hookdeck: %v", err) } if session.Id == "" { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - p.cfg.Log.Fatalf("Error while starting a new session") + p.renderer.OnError(fmt.Errorf("error while starting a new session")) + p.renderer.Cleanup() + return fmt.Errorf("error while starting a new session") } // Main loop to keep attempting to connect to Hookdeck once @@ -141,14 +144,10 @@ func (p *Proxy) Run(parentCtx context.Context) error { }, ) - // Monitor the websocket for connection and update the spinner appropriately. + // Monitor the websocket for connection go func() { <-p.webSocketClient.Connected() - msg := "Ready! (^C to quit)" - if hasConnectedOnce { - msg = "Reconnected!" - } - ansi.StopSpinner(s, msg, p.cfg.Log.Out) + p.renderer.OnConnected() hasConnectedOnce = true }() @@ -156,25 +155,38 @@ func (p *Proxy) Run(parentCtx context.Context) error { go p.webSocketClient.Run(signalCtx) nAttempts++ - // Block until ctrl+c or the websocket connection is interrupted + // Block until ctrl+c, renderer quit, or websocket connection is interrupted select { case <-signalCtx.Done(): - ansi.StopSpinner(s, "", p.cfg.Log.Out) + return nil + case <-p.renderer.Done(): + // Renderer wants to quit (user pressed q or similar) + if p.webSocketClient != nil { + p.webSocketClient.Stop() + } + p.renderer.Cleanup() return nil case <-p.webSocketClient.NotifyExpired: - if canConnect() { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - s = ansi.StartNewSpinner("Connection lost, reconnecting...", p.cfg.Log.Out) - } else { - p.cfg.Log.Fatalf("Session expired. Terminating after %d failed attempts to reauthorize", nAttempts) + p.renderer.OnDisconnected() + if !canConnect() { + p.renderer.Cleanup() + return fmt.Errorf("Could not connect. Terminating after %d failed attempts to establish a connection.", nAttempts) } } - // Determine if we should backoff the connection retries. - attemptsOverMax := math.Max(0, float64(nAttempts-maxConnectAttempts)) - if canConnect() && attemptsOverMax > 0 { - // Determine the time to wait to reconnect, maximum of 10 second intervals - sleepDurationMS := int(math.Round(math.Min(100, math.Pow(attemptsOverMax, 2)) * 100)) + // Add backoff delay between all retry attempts + if canConnect() { + var sleepDurationMS int + + if nAttempts <= maxConnectAttempts { + // First 10 attempts: use a fixed 2 second delay + sleepDurationMS = 2000 + } else { + // After max attempts: exponential backoff, maximum of 10 second intervals + attemptsOverMax := float64(nAttempts - maxConnectAttempts) + sleepDurationMS = int(math.Round(math.Min(100, math.Pow(attemptsOverMax, 2)) * 100)) + } + log.WithField( "prefix", "proxy.Proxy.Run", ).Debugf( @@ -199,6 +211,9 @@ func (p *Proxy) Run(parentCtx context.Context) error { p.webSocketClient.Stop() } + // Clean up renderer + p.renderer.Cleanup() + log.WithFields(log.Fields{ "prefix": "proxy.Proxy.Run", }).Debug("Bye!") @@ -251,6 +266,7 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { } webhookEvent := msg.Attempt + eventID := webhookEvent.Body.EventID p.cfg.Log.WithFields(log.Fields{ "prefix": "proxy.Proxy.processAttempt", @@ -278,12 +294,7 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { // Warn when approaching connection limit if activeCount > warningThreshold && !p.maxConnWarned { p.maxConnWarned = true - color := ansi.Color(os.Stdout) - fmt.Printf("\n%s High connection load detected (%d active requests)\n", - color.Yellow("⚠ WARNING:"), activeCount) - fmt.Printf(" The CLI is limited to %d concurrent connections per host.\n", p.transport.MaxConnsPerHost) - fmt.Printf(" Consider reducing request rate or increasing connection limit.\n") - fmt.Printf(" Run with --max-connections=%d to increase the limit.\n\n", maxConns*2) + p.renderer.OnConnectionWarning(activeCount, p.transport.MaxConnsPerHost) } else if activeCount < resetThreshold && p.maxConnWarned { // Reset warning flag when load decreases p.maxConnWarned = false @@ -294,13 +305,13 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { req, err := http.NewRequestWithContext(ctx, webhookEvent.Body.Request.Method, url, nil) if err != nil { - fmt.Printf("Error: %s\n", err) + p.renderer.OnEventError(eventID, webhookEvent, err, time.Now()) return } x := make(map[string]json.RawMessage) err = json.Unmarshal(webhookEvent.Body.Request.Headers, &x) if err != nil { - fmt.Printf("Error: %s\n", err) + p.renderer.OnEventError(eventID, webhookEvent, err, time.Now()) return } @@ -312,65 +323,102 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) { req.Body = ioutil.NopCloser(strings.NewReader(webhookEvent.Body.Request.DataString)) req.ContentLength = int64(len(webhookEvent.Body.Request.DataString)) - res, err := p.httpClient.Do(req) - if err != nil { - color := ansi.Color(os.Stdout) - localTime := time.Now().Format(timeLayout) - - // Use the original error message - errStr := fmt.Sprintf("%s [%s] Failed to %s: %s", - color.Faint(localTime), - color.Red("ERROR"), - webhookEvent.Body.Request.Method, - err, - ) + // For interactive mode: start 100ms timer and HTTP request concurrently + requestStartTime := time.Now() - fmt.Println(errStr) - p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ - ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ - Event: "attempt_response", - Body: websocket.ErrorAttemptBody{ - AttemptId: webhookEvent.Body.AttemptId, - Error: true, - }, - }}) - } else { - // Process the response (this reads the entire body) - p.processEndpointResponse(webhookEvent, res) + // Channel to receive HTTP response or error + type httpResult struct { + res *http.Response + err error + } + responseCh := make(chan httpResult, 1) - // Close the body - connection can be reused since body was fully read - res.Body.Close() + // Make HTTP request in goroutine + go func() { + res, err := p.httpClient.Do(req) + responseCh <- httpResult{res: res, err: err} + }() + + // For interactive mode, wait 100ms before showing pending event + timer := time.NewTimer(100 * time.Millisecond) + defer timer.Stop() + + var eventShown bool + var result httpResult + + select { + case result = <-responseCh: + // Response came back within 100ms - show final event immediately + timer.Stop() + if result.err != nil { + p.renderer.OnEventError(eventID, webhookEvent, result.err, requestStartTime) + p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ + ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ + Event: "attempt_response", + Body: websocket.ErrorAttemptBody{ + AttemptId: webhookEvent.Body.AttemptId, + Error: true, + }, + }}) + } else { + p.processEndpointResponse(eventID, webhookEvent, result.res, requestStartTime) + result.res.Body.Close() + } + return + + case <-timer.C: + // 100ms passed - show pending event (interactive mode only) + eventShown = true + p.renderer.OnEventPending(eventID, webhookEvent, requestStartTime) + + // Wait for HTTP response to complete + result = <-responseCh } -} -func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *http.Response) { - localTime := time.Now().Format(timeLayout) - color := ansi.Color(os.Stdout) - var url = p.cfg.DashboardBaseURL + "/cli/events/" + webhookEvent.Body.EventID - if p.cfg.ProjectMode == "console" { - url = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID + // If we showed pending event, now handle the final result + if eventShown { + if result.err != nil { + p.renderer.OnEventError(eventID, webhookEvent, result.err, requestStartTime) + p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ + ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ + Event: "attempt_response", + Body: websocket.ErrorAttemptBody{ + AttemptId: webhookEvent.Body.AttemptId, + Error: true, + }, + }}) + } else { + p.processEndpointResponse(eventID, webhookEvent, result.res, requestStartTime) + result.res.Body.Close() + } } - outputStr := fmt.Sprintf("%s [%d] %s %s | %s", - color.Faint(localTime), - ansi.ColorizeStatus(resp.StatusCode), - resp.Request.Method, - resp.Request.URL, - url, - ) - fmt.Println(outputStr) +} +func (p *Proxy) processEndpointResponse(eventID string, webhookEvent *websocket.Attempt, resp *http.Response, requestStartTime time.Time) { buf, err := ioutil.ReadAll(resp.Body) if err != nil { - errStr := fmt.Sprintf("%s [%s] Failed to read response from endpoint, error = %v\n", - color.Faint(localTime), - color.Red("ERROR"), - err, - ) - log.Errorf(errStr) - + log.Errorf("Failed to read response from endpoint, error = %v\n", err) return } + // Calculate response duration + responseDuration := time.Since(requestStartTime) + + // Prepare response headers + responseHeaders := make(map[string][]string) + for key, values := range resp.Header { + responseHeaders[key] = values + } + + // Call renderer with response data + p.renderer.OnEventComplete(eventID, webhookEvent, &EventResponse{ + StatusCode: resp.StatusCode, + Headers: responseHeaders, + Body: string(buf), + Duration: responseDuration, + }, requestStartTime) + + // Send response back to Hookdeck if p.webSocketClient != nil { p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ AttemptResponse: &websocket.AttemptResponse{ @@ -390,7 +438,7 @@ func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *h // // New creates a new Proxy -func New(cfg *Config, connections []*hookdecksdk.Connection) *Proxy { +func New(cfg *Config, connections []*hookdecksdk.Connection, renderer Renderer) *Proxy { if cfg.Log == nil { cfg.Log = &log.Logger{Out: ioutil.Discard} } @@ -405,12 +453,12 @@ func New(cfg *Config, connections []*hookdecksdk.Connection) *Proxy { tr := &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.Insecure}, // Connection pool settings - sensible defaults for typical usage - MaxIdleConns: 20, // Total idle connections across all hosts - MaxIdleConnsPerHost: 10, // Keep some idle connections for reuse + MaxIdleConns: 20, // Total idle connections across all hosts + MaxIdleConnsPerHost: 10, // Keep some idle connections for reuse IdleConnTimeout: 30 * time.Second, // Clean up idle connections DisableKeepAlives: false, // Limit concurrent connections to prevent resource exhaustion - MaxConnsPerHost: maxConns, // User-configurable (default: 50) + MaxConnsPerHost: maxConns, // User-configurable (default: 50) ResponseHeaderTimeout: 60 * time.Second, } @@ -423,6 +471,7 @@ func New(cfg *Config, connections []*hookdecksdk.Connection) *Proxy { Transport: tr, // Timeout is controlled per-request via context in processAttempt }, + renderer: renderer, } return p diff --git a/pkg/proxy/proxy_tui.go b/pkg/proxy/proxy_tui.go deleted file mode 100644 index 2648dfb..0000000 --- a/pkg/proxy/proxy_tui.go +++ /dev/null @@ -1,801 +0,0 @@ -package proxy - -import ( - "context" - "crypto/tls" - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "math" - "net/http" - "net/url" - "os" - "strconv" - "strings" - "sync/atomic" - "time" - - "github.com/briandowns/spinner" - tea "github.com/charmbracelet/bubbletea" - log "github.com/sirupsen/logrus" - - "github.com/hookdeck/hookdeck-cli/pkg/ansi" - "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" - "github.com/hookdeck/hookdeck-cli/pkg/tui" - "github.com/hookdeck/hookdeck-cli/pkg/websocket" - hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" -) - -// ProxyTUI is a Proxy that uses Bubble Tea for interactive mode -type ProxyTUI struct { - cfg *Config - connections []*hookdecksdk.Connection - webSocketClient *websocket.Client - connectionTimer *time.Timer - - // HTTP client with connection pooling - httpClient *http.Client - transport *http.Transport - activeRequests int32 // atomic counter - maxConnWarned bool // Track if we've warned about connection limit - - // Bubble Tea program - teaProgram *tea.Program - teaModel *tui.Model -} - -// NewTUI creates a new Proxy with Bubble Tea UI -func NewTUI(cfg *Config, sources []*hookdecksdk.Source, connections []*hookdecksdk.Connection) *ProxyTUI { - if cfg.Log == nil { - cfg.Log = &log.Logger{Out: ioutil.Discard} - } - - // Default to interactive mode if not specified - if cfg.Output == "" { - cfg.Output = "interactive" - } - - // Default to 50 connections if not specified - maxConns := cfg.MaxConnections - if maxConns <= 0 { - maxConns = 50 - } - - // Create a shared HTTP transport with connection pooling - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.Insecure}, - // Connection pool settings - sensible defaults for typical usage - MaxIdleConns: 20, // Total idle connections across all hosts - MaxIdleConnsPerHost: 10, // Keep some idle connections for reuse - IdleConnTimeout: 30 * time.Second, // Clean up idle connections - DisableKeepAlives: false, - // Limit concurrent connections to prevent resource exhaustion - MaxConnsPerHost: maxConns, // User-configurable (default: 50) - ResponseHeaderTimeout: 60 * time.Second, - } - - p := &ProxyTUI{ - cfg: cfg, - connections: connections, - connectionTimer: time.NewTimer(0), - transport: tr, - httpClient: &http.Client{ - Transport: tr, - // Timeout is controlled per-request via context in processAttempt - }, - } - - // Only create Bubble Tea program for interactive mode - if cfg.Output == "interactive" { - tuiCfg := &tui.Config{ - DeviceName: cfg.DeviceName, - APIKey: cfg.Key, - APIBaseURL: cfg.APIBaseURL, - DashboardBaseURL: cfg.DashboardBaseURL, - ConsoleBaseURL: cfg.ConsoleBaseURL, - ProjectMode: cfg.ProjectMode, - ProjectID: cfg.ProjectID, - GuestURL: cfg.GuestURL, - TargetURL: cfg.URL, - Sources: sources, - Connections: connections, - } - model := tui.NewModel(tuiCfg) - p.teaModel = &model - // Use alt screen to keep terminal clean - p.teaProgram = tea.NewProgram(&model, tea.WithAltScreen()) - } - - return p -} - -// Run manages the connection to Hookdeck with Bubble Tea UI -func (p *ProxyTUI) Run(parentCtx context.Context) error { - const maxConnectAttempts = 10 - const maxReconnectAttempts = 10 - nAttempts := 0 - - hasConnectedOnce := false - canConnect := func() bool { - if hasConnectedOnce { - return nAttempts < maxReconnectAttempts - } - return nAttempts < maxConnectAttempts - } - - signalCtx := withSIGTERMCancel(parentCtx, func() { - log.WithFields(log.Fields{ - "prefix": "proxy.ProxyTUI.Run", - }).Debug("Ctrl+C received, cleaning up...") - }) - - // Create a channel to signal when TUI exits - tuiDoneCh := make(chan struct{}) - - // Start Bubble Tea program in interactive mode immediately - if p.cfg.Output == "interactive" && p.teaProgram != nil { - go func() { - if _, err := p.teaProgram.Run(); err != nil { - log.WithField("prefix", "proxy.ProxyTUI.Run"). - Errorf("Bubble Tea error: %v", err) - } - // Signal that TUI has exited (user pressed q or Ctrl+C) - close(tuiDoneCh) - }() - } - - // For non-interactive modes, show spinner - var s *spinner.Spinner - if p.cfg.Output != "interactive" { - s = ansi.StartNewSpinner("Getting ready...", p.cfg.Log.Out) - } - - // Send connecting message to TUI - if p.teaProgram != nil { - p.teaProgram.Send(tui.ConnectingMsg{}) - } - - session, err := p.createSession(signalCtx) - if err != nil { - if s != nil { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - } - if p.teaProgram != nil { - p.teaProgram.Kill() - } - p.cfg.Log.Fatalf("Error while authenticating with Hookdeck: %v", err) - } - - if session.Id == "" { - if s != nil { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - } - if p.teaProgram != nil { - p.teaProgram.Kill() - } - p.cfg.Log.Fatalf("Error while starting a new session") - } - - // Main connection loop - for canConnect() { - // Apply backoff delay - if nAttempts > 0 { - backoffMS := math.Min(100*math.Pow(2, float64(nAttempts-1)), 30000) - sleepDurationMS := int(backoffMS) - - log.WithField("prefix", "proxy.ProxyTUI.Run"). - Debugf("Connect backoff (%dms)", sleepDurationMS) - - p.connectionTimer.Stop() - p.connectionTimer.Reset(time.Duration(sleepDurationMS) * time.Millisecond) - - // For non-interactive modes, update spinner - if s != nil { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - if hasConnectedOnce { - s = ansi.StartNewSpinner("Connection lost, reconnecting...", p.cfg.Log.Out) - } else { - s = ansi.StartNewSpinner("Connecting...", p.cfg.Log.Out) - } - } - - // For interactive mode, send reconnecting message to TUI - if p.teaProgram != nil { - if hasConnectedOnce { - p.teaProgram.Send(tui.DisconnectedMsg{}) - } else { - p.teaProgram.Send(tui.ConnectingMsg{}) - } - } - - select { - case <-p.connectionTimer.C: - // Continue to retry - case <-signalCtx.Done(): - p.connectionTimer.Stop() - if s != nil { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - } - if p.teaProgram != nil { - p.teaProgram.Kill() - } - return nil - case <-tuiDoneCh: - // TUI exited during backoff - p.connectionTimer.Stop() - if s != nil { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - } - if p.webSocketClient != nil { - p.webSocketClient.Stop() - } - return nil - } - } - - p.webSocketClient = websocket.NewClient( - p.cfg.WSBaseURL, - session.Id, - p.cfg.Key, - p.cfg.ProjectID, - &websocket.Config{ - Log: p.cfg.Log, - NoWSS: p.cfg.NoWSS, - EventHandler: websocket.EventHandlerFunc(p.processAttempt), - }, - ) - - // Monitor websocket connection - go func() { - <-p.webSocketClient.Connected() - nAttempts = 0 - - // For non-interactive modes, stop spinner and show message - if s != nil { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - color := ansi.Color(os.Stdout) - fmt.Printf("%s\n\n", color.Faint("Connected. Waiting for events...")) - } - - // Send connected message to TUI - if p.teaProgram != nil { - p.teaProgram.Send(tui.ConnectedMsg{}) - } - - hasConnectedOnce = true - }() - - // Run websocket - go p.webSocketClient.Run(signalCtx) - nAttempts++ - - // Block until ctrl+c, TUI exits, or connection lost - select { - case <-signalCtx.Done(): - if s != nil { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - } - if p.teaProgram != nil { - p.teaProgram.Kill() - } - return nil - case <-tuiDoneCh: - // TUI exited (user pressed q or Ctrl+C in TUI) - if s != nil { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - } - if p.webSocketClient != nil { - p.webSocketClient.Stop() - } - return nil - case <-p.webSocketClient.NotifyExpired: - // Send disconnected message - if p.teaProgram != nil { - p.teaProgram.Send(tui.DisconnectedMsg{}) - } - - if !canConnect() { - if s != nil { - ansi.StopSpinner(s, "", p.cfg.Log.Out) - } - if p.teaProgram != nil { - p.teaProgram.Quit() - // Wait a moment for TUI to clean up properly - select { - case <-tuiDoneCh: - // TUI exited cleanly - case <-time.After(100 * time.Millisecond): - // Timeout, force kill - p.teaProgram.Kill() - } - } - - return fmt.Errorf("Could not establish connection. Terminating after %d attempts to connect", nAttempts) - } - } - } - - if p.webSocketClient != nil { - p.webSocketClient.Stop() - } - - if p.teaProgram != nil { - p.teaProgram.Kill() - } - - log.WithFields(log.Fields{ - "prefix": "proxy.ProxyTUI.Run", - }).Debug("Bye!") - - return nil -} - -func (p *ProxyTUI) createSession(ctx context.Context) (hookdeck.Session, error) { - var session hookdeck.Session - - parsedBaseURL, err := url.Parse(p.cfg.APIBaseURL) - if err != nil { - return session, err - } - - client := &hookdeck.Client{ - BaseURL: parsedBaseURL, - APIKey: p.cfg.Key, - ProjectID: p.cfg.ProjectID, - } - - var connectionIDs []string - for _, connection := range p.connections { - connectionIDs = append(connectionIDs, connection.Id) - } - - for i := 0; i <= 5; i++ { - session, err = client.CreateSession(hookdeck.CreateSessionInput{ - ConnectionIds: connectionIDs, - }) - - if err == nil { - return session, nil - } - - select { - case <-ctx.Done(): - return session, errors.New("canceled by context") - case <-time.After(1 * time.Second): - } - } - - return session, err -} - -func (p *ProxyTUI) processAttempt(msg websocket.IncomingMessage) { - if msg.Attempt == nil { - p.cfg.Log.Debug("WebSocket specified for Events received unexpected event") - return - } - - webhookEvent := msg.Attempt - eventID := webhookEvent.Body.EventID - - p.cfg.Log.WithFields(log.Fields{ - "prefix": "proxy.ProxyTUI.processAttempt", - }).Debugf("Processing webhook event") - - url := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path - - // Create request with context for timeout control - timeout := webhookEvent.Body.Request.Timeout - if timeout == 0 { - timeout = 1000 * 30 - } - - // Track active requests - atomic.AddInt32(&p.activeRequests, 1) - defer atomic.AddInt32(&p.activeRequests, -1) - - activeCount := atomic.LoadInt32(&p.activeRequests) - - // Calculate warning thresholds proportionally to max connections - maxConns := int32(p.transport.MaxConnsPerHost) - warningThreshold := int32(float64(maxConns) * 0.8) // Warn at 80% capacity - resetThreshold := int32(float64(maxConns) * 0.6) // Reset warning at 60% capacity - - // Warn when approaching connection limit - if activeCount > warningThreshold && !p.maxConnWarned { - p.maxConnWarned = true - color := ansi.Color(os.Stdout) - fmt.Printf("\n%s High connection load detected (%d active requests)\n", - color.Yellow("⚠ WARNING:"), activeCount) - fmt.Printf(" The CLI is limited to %d concurrent connections per host.\n", p.transport.MaxConnsPerHost) - fmt.Printf(" Consider reducing request rate or increasing connection limit.\n") - fmt.Printf(" Run with --max-connections=%d to increase the limit.\n\n", maxConns*2) - } else if activeCount < resetThreshold && p.maxConnWarned { - // Reset warning flag when load decreases - p.maxConnWarned = false - } - - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Millisecond) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, webhookEvent.Body.Request.Method, url, nil) - if err != nil { - fmt.Printf("Error: %s\n", err) - return - } - - x := make(map[string]json.RawMessage) - err = json.Unmarshal(webhookEvent.Body.Request.Headers, &x) - if err != nil { - fmt.Printf("Error: %s\n", err) - return - } - - for key, value := range x { - unquoted_value, _ := strconv.Unquote(string(value)) - req.Header.Set(key, unquoted_value) - } - - req.Body = ioutil.NopCloser(strings.NewReader(webhookEvent.Body.Request.DataString)) - req.ContentLength = int64(len(webhookEvent.Body.Request.DataString)) - - // Start 100ms timer and HTTP request concurrently - requestStartTime := time.Now() - eventTime := requestStartTime - - // Channel to receive HTTP response or error - type httpResult struct { - res *http.Response - err error - } - responseCh := make(chan httpResult, 1) - - // Make HTTP request in goroutine - go func() { - res, err := p.httpClient.Do(req) - responseCh <- httpResult{res: res, err: err} - }() - - // Wait for either 100ms to pass or HTTP response to arrive - timer := time.NewTimer(100 * time.Millisecond) - defer timer.Stop() - - var eventShown bool - var result httpResult - - select { - case result = <-responseCh: - // Response came back within 100ms - show final event immediately - timer.Stop() - if result.err != nil { - p.handleRequestError(eventID, webhookEvent, result.err) - } else { - p.processEndpointResponse(webhookEvent, result.res, requestStartTime) - } - return - - case <-timer.C: - // 100ms passed - show pending event - eventShown = true - p.showPendingEvent(eventID, webhookEvent, eventTime) - - // Wait for HTTP response to complete - result = <-responseCh - } - - // If we showed pending event, now update it with final result - if eventShown { - if result.err != nil { - p.updateEventWithError(eventID, webhookEvent, result.err, eventTime) - } else { - p.updateEventWithResponse(eventID, webhookEvent, result.res, requestStartTime, eventTime) - } - } -} - -func (p *ProxyTUI) showPendingEvent(eventID string, webhookEvent *websocket.Attempt, eventTime time.Time) { - color := ansi.Color(os.Stdout) - localTime := eventTime.Format(timeLayout) - - pendingStr := fmt.Sprintf("%s [%s] %s %s %s", - color.Faint(localTime), - color.Faint("..."), - webhookEvent.Body.Request.Method, - fmt.Sprintf("http://localhost%s", webhookEvent.Body.Path), - color.Faint("(Waiting for response)"), - ) - - // Send pending event to UI - event := tui.EventInfo{ - ID: eventID, - AttemptID: webhookEvent.Body.AttemptId, - Status: 0, - Success: false, - Time: eventTime, - Data: webhookEvent, - LogLine: pendingStr, - ResponseStatus: 0, - ResponseDuration: 0, - } - - switch p.cfg.Output { - case "interactive": - if p.teaProgram != nil { - p.teaProgram.Send(tui.NewEventMsg{Event: event}) - } - case "compact": - fmt.Println(pendingStr) - case "quiet": - // Don't show pending events in quiet mode - } -} - -func (p *ProxyTUI) updateEventWithError(eventID string, webhookEvent *websocket.Attempt, err error, eventTime time.Time) { - color := ansi.Color(os.Stdout) - localTime := eventTime.Format(timeLayout) - - errStr := fmt.Sprintf("%s [%s] Failed to %s: %v", - color.Faint(localTime), - color.Red("ERROR").Bold(), - webhookEvent.Body.Request.Method, - err, - ) - - // Update event in UI - switch p.cfg.Output { - case "interactive": - if p.teaProgram != nil { - p.teaProgram.Send(tui.UpdateEventMsg{ - EventID: eventID, - Time: eventTime, - Status: 0, - Success: false, - LogLine: errStr, - ResponseStatus: 0, - ResponseHeaders: nil, - ResponseBody: "", - ResponseDuration: 0, - }) - } - case "compact": - fmt.Println(errStr) - case "quiet": - fmt.Println(errStr) - } - - p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ - ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ - Event: "attempt_response", - Body: websocket.ErrorAttemptBody{ - AttemptId: webhookEvent.Body.AttemptId, - Error: true, - }, - }}) -} - -func (p *ProxyTUI) updateEventWithResponse(eventID string, webhookEvent *websocket.Attempt, resp *http.Response, requestStartTime time.Time, eventTime time.Time) { - localTime := eventTime.Format(timeLayout) - color := ansi.Color(os.Stdout) - - // Build display URL (without team_id for cleaner display) - var displayURL string - if p.cfg.ProjectMode == "console" { - displayURL = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID - } else { - displayURL = p.cfg.DashboardBaseURL + "/events/" + webhookEvent.Body.EventID - } - - responseDuration := eventTime.Sub(requestStartTime) - durationMs := responseDuration.Milliseconds() - - outputStr := fmt.Sprintf("%s [%d] %s %s %s %s %s", - color.Faint(localTime), - ansi.ColorizeStatus(resp.StatusCode), - resp.Request.Method, - resp.Request.URL, - color.Faint(fmt.Sprintf("(%dms)", durationMs)), - color.Faint("→"), - color.Faint(displayURL), - ) - - eventStatus := resp.StatusCode - eventSuccess := resp.StatusCode >= 200 && resp.StatusCode < 300 - - buf, err := ioutil.ReadAll(resp.Body) - if err != nil { - errStr := fmt.Sprintf("%s [%s] Failed to read response from endpoint, error = %v\n", - color.Faint(localTime), - color.Red("ERROR").Bold(), - err, - ) - log.Errorf(errStr) - resp.Body.Close() - return - } - - // Close the body - connection can be reused since body was fully read - resp.Body.Close() - - responseHeaders := make(map[string][]string) - for key, values := range resp.Header { - responseHeaders[key] = values - } - responseBody := string(buf) - - // Update event in UI - switch p.cfg.Output { - case "interactive": - if p.teaProgram != nil { - p.teaProgram.Send(tui.UpdateEventMsg{ - EventID: eventID, - Time: eventTime, - Status: eventStatus, - Success: eventSuccess, - LogLine: outputStr, - ResponseStatus: eventStatus, - ResponseHeaders: responseHeaders, - ResponseBody: responseBody, - ResponseDuration: responseDuration, - }) - } - case "compact": - fmt.Println(outputStr) - case "quiet": - // Only print fatal errors - if !eventSuccess && eventStatus == 0 { - fmt.Println(outputStr) - } - } - - if p.webSocketClient != nil { - p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ - AttemptResponse: &websocket.AttemptResponse{ - Event: "attempt_response", - Body: websocket.AttemptResponseBody{ - AttemptId: webhookEvent.Body.AttemptId, - CLIPath: webhookEvent.Body.Path, - Status: resp.StatusCode, - Data: string(buf), - }, - }}) - } -} - -func (p *ProxyTUI) handleRequestError(eventID string, webhookEvent *websocket.Attempt, err error) { - color := ansi.Color(os.Stdout) - localTime := time.Now().Format(timeLayout) - - errStr := fmt.Sprintf("%s [%s] Failed to %s: %v", - color.Faint(localTime), - color.Red("ERROR").Bold(), - webhookEvent.Body.Request.Method, - err, - ) - - // Send event to UI - event := tui.EventInfo{ - ID: eventID, - AttemptID: webhookEvent.Body.AttemptId, - Status: 0, - Success: false, - Time: time.Now(), - Data: webhookEvent, - LogLine: errStr, - ResponseStatus: 0, - ResponseDuration: 0, - } - - switch p.cfg.Output { - case "interactive": - if p.teaProgram != nil { - p.teaProgram.Send(tui.NewEventMsg{Event: event}) - } - case "compact": - fmt.Println(errStr) - case "quiet": - fmt.Println(errStr) - } - - p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ - ErrorAttemptResponse: &websocket.ErrorAttemptResponse{ - Event: "attempt_response", - Body: websocket.ErrorAttemptBody{ - AttemptId: webhookEvent.Body.AttemptId, - Error: true, - }, - }}) -} - -func (p *ProxyTUI) processEndpointResponse(webhookEvent *websocket.Attempt, resp *http.Response, requestStartTime time.Time) { - eventTime := time.Now() - localTime := eventTime.Format(timeLayout) - color := ansi.Color(os.Stdout) - - // Build display URL (without team_id for cleaner display) - var displayURL string - if p.cfg.ProjectMode == "console" { - displayURL = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID - } else { - displayURL = p.cfg.DashboardBaseURL + "/events/" + webhookEvent.Body.EventID - } - - responseDuration := eventTime.Sub(requestStartTime) - durationMs := responseDuration.Milliseconds() - - outputStr := fmt.Sprintf("%s [%d] %s %s %s %s %s", - color.Faint(localTime), - ansi.ColorizeStatus(resp.StatusCode), - resp.Request.Method, - resp.Request.URL, - color.Faint(fmt.Sprintf("(%dms)", durationMs)), - color.Faint("→"), - color.Faint(displayURL), - ) - - eventStatus := resp.StatusCode - eventSuccess := resp.StatusCode >= 200 && resp.StatusCode < 300 - eventID := webhookEvent.Body.EventID - - buf, err := ioutil.ReadAll(resp.Body) - if err != nil { - errStr := fmt.Sprintf("%s [%s] Failed to read response from endpoint, error = %v\n", - color.Faint(localTime), - color.Red("ERROR").Bold(), - err, - ) - log.Errorf(errStr) - resp.Body.Close() - return - } - - // Close the body - connection can be reused since body was fully read - resp.Body.Close() - - responseHeaders := make(map[string][]string) - for key, values := range resp.Header { - responseHeaders[key] = values - } - responseBody := string(buf) - - // Send event to UI - event := tui.EventInfo{ - ID: eventID, - AttemptID: webhookEvent.Body.AttemptId, - Status: eventStatus, - Success: eventSuccess, - Time: eventTime, - Data: webhookEvent, - LogLine: outputStr, - ResponseStatus: eventStatus, - ResponseHeaders: responseHeaders, - ResponseBody: responseBody, - ResponseDuration: responseDuration, - } - - switch p.cfg.Output { - case "interactive": - if p.teaProgram != nil { - p.teaProgram.Send(tui.NewEventMsg{Event: event}) - } - case "compact": - fmt.Println(outputStr) - case "quiet": - // Only print fatal errors - if !eventSuccess && eventStatus == 0 { - fmt.Println(outputStr) - } - } - - if p.webSocketClient != nil { - p.webSocketClient.SendMessage(&websocket.OutgoingMessage{ - AttemptResponse: &websocket.AttemptResponse{ - Event: "attempt_response", - Body: websocket.AttemptResponseBody{ - AttemptId: webhookEvent.Body.AttemptId, - CLIPath: webhookEvent.Body.Path, - Status: resp.StatusCode, - Data: string(buf), - }, - }}) - } -} diff --git a/pkg/proxy/renderer.go b/pkg/proxy/renderer.go new file mode 100644 index 0000000..23f0234 --- /dev/null +++ b/pkg/proxy/renderer.go @@ -0,0 +1,71 @@ +package proxy + +import ( + "net/url" + "time" + + "github.com/hookdeck/hookdeck-cli/pkg/websocket" + hookdecksdk "github.com/hookdeck/hookdeck-go-sdk" +) + +// Renderer is the interface for handling proxy output +// Implementations handle different output modes (interactive, compact, quiet) +type Renderer interface { + // Lifecycle events + OnConnecting() + OnConnected() + OnDisconnected() + OnError(err error) + + // Event handling + OnEventPending(eventID string, attempt *websocket.Attempt, startTime time.Time) // For interactive mode (100ms delay) + OnEventComplete(eventID string, attempt *websocket.Attempt, response *EventResponse, startTime time.Time) + OnEventError(eventID string, attempt *websocket.Attempt, err error, startTime time.Time) + + // Connection warnings + OnConnectionWarning(activeRequests int32, maxConns int) + + // Cleanup is called before exit to clean up resources (e.g., stop TUI, stop spinner) + Cleanup() + + // Done returns a channel that signals when user wants to quit + Done() <-chan struct{} +} + +// EventResponse contains the HTTP response data +type EventResponse struct { + StatusCode int + Headers map[string][]string + Body string + Duration time.Duration +} + +// RendererConfig contains configuration for creating renderers +type RendererConfig struct { + DeviceName string + APIKey string + APIBaseURL string + DashboardBaseURL string + ConsoleBaseURL string + ProjectMode string + ProjectID string + GuestURL string + TargetURL *url.URL + Output string + Sources []*hookdecksdk.Source + Connections []*hookdecksdk.Connection +} + +// NewRenderer creates the appropriate renderer based on output mode +func NewRenderer(cfg *RendererConfig) Renderer { + switch cfg.Output { + case "interactive": + return NewInteractiveRenderer(cfg) + case "compact": + return NewSimpleRenderer(cfg, false) // verbose mode + case "quiet": + return NewSimpleRenderer(cfg, true) // quiet mode + default: + return NewSimpleRenderer(cfg, false) + } +} diff --git a/pkg/proxy/renderer_interactive.go b/pkg/proxy/renderer_interactive.go new file mode 100644 index 0000000..d3b5d0c --- /dev/null +++ b/pkg/proxy/renderer_interactive.go @@ -0,0 +1,229 @@ +package proxy + +import ( + "fmt" + "os" + "time" + + tea "github.com/charmbracelet/bubbletea" + log "github.com/sirupsen/logrus" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/tui" + "github.com/hookdeck/hookdeck-cli/pkg/websocket" +) + +const interactiveTimeLayout = "2006-01-02 15:04:05" + +// InteractiveRenderer renders events using Bubble Tea TUI +type InteractiveRenderer struct { + cfg *RendererConfig + teaProgram *tea.Program + teaModel *tui.Model + doneCh chan struct{} +} + +// NewInteractiveRenderer creates a new interactive renderer with Bubble Tea +func NewInteractiveRenderer(cfg *RendererConfig) *InteractiveRenderer { + tuiCfg := &tui.Config{ + DeviceName: cfg.DeviceName, + APIKey: cfg.APIKey, + APIBaseURL: cfg.APIBaseURL, + DashboardBaseURL: cfg.DashboardBaseURL, + ConsoleBaseURL: cfg.ConsoleBaseURL, + ProjectMode: cfg.ProjectMode, + ProjectID: cfg.ProjectID, + GuestURL: cfg.GuestURL, + TargetURL: cfg.TargetURL, + Sources: cfg.Sources, + Connections: cfg.Connections, + } + + model := tui.NewModel(tuiCfg) + program := tea.NewProgram(&model, tea.WithAltScreen()) + + r := &InteractiveRenderer{ + cfg: cfg, + teaProgram: program, + teaModel: &model, + doneCh: make(chan struct{}), + } + + // Start TUI in background + go func() { + if _, err := r.teaProgram.Run(); err != nil { + log.WithField("prefix", "proxy.InteractiveRenderer"). + Errorf("Bubble Tea error: %v", err) + } + // Signal that TUI has exited + close(r.doneCh) + }() + + return r +} + +// OnConnecting is called when starting to connect +func (r *InteractiveRenderer) OnConnecting() { + if r.teaProgram != nil { + r.teaProgram.Send(tui.ConnectingMsg{}) + } +} + +// OnConnected is called when websocket connects +func (r *InteractiveRenderer) OnConnected() { + if r.teaProgram != nil { + r.teaProgram.Send(tui.ConnectedMsg{}) + } +} + +// OnDisconnected is called when websocket disconnects +func (r *InteractiveRenderer) OnDisconnected() { + if r.teaProgram != nil { + r.teaProgram.Send(tui.DisconnectedMsg{}) + } +} + +// OnError is called when an error occurs +func (r *InteractiveRenderer) OnError(err error) { + // Errors are handled through OnEventError +} + +// OnEventPending is called when an event starts (after 100ms delay) +func (r *InteractiveRenderer) OnEventPending(eventID string, attempt *websocket.Attempt, startTime time.Time) { + r.showPendingEvent(eventID, attempt, startTime) +} + +// OnEventComplete is called when an event completes successfully +func (r *InteractiveRenderer) OnEventComplete(eventID string, attempt *websocket.Attempt, response *EventResponse, startTime time.Time) { + eventTime := time.Now() + localTime := eventTime.Format(interactiveTimeLayout) + color := ansi.Color(os.Stdout) + + var displayURL string + if r.cfg.ProjectMode == "console" { + displayURL = r.cfg.ConsoleBaseURL + "/?event_id=" + eventID + } else { + displayURL = r.cfg.DashboardBaseURL + "/events/" + eventID + } + + durationMs := response.Duration.Milliseconds() + + outputStr := fmt.Sprintf("%s [%d] %s %s %s %s %s", + color.Faint(localTime), + ansi.ColorizeStatus(response.StatusCode), + attempt.Body.Request.Method, + r.cfg.TargetURL.Scheme+"://"+r.cfg.TargetURL.Host+r.cfg.TargetURL.Path+attempt.Body.Path, + color.Faint(fmt.Sprintf("(%dms)", durationMs)), + color.Faint("→"), + color.Faint(displayURL), + ) + + eventStatus := response.StatusCode + eventSuccess := response.StatusCode >= 200 && response.StatusCode < 300 + + // Send update message to TUI (will update existing pending event or create new if not found) + if r.teaProgram != nil { + r.teaProgram.Send(tui.UpdateEventMsg{ + EventID: eventID, + AttemptID: attempt.Body.AttemptId, + Time: startTime, + Data: attempt, + Status: eventStatus, + Success: eventSuccess, + LogLine: outputStr, + ResponseStatus: eventStatus, + ResponseHeaders: response.Headers, + ResponseBody: response.Body, + ResponseDuration: response.Duration, + }) + } +} + +// showPendingEvent shows a pending event (waiting for response) +func (r *InteractiveRenderer) showPendingEvent(eventID string, attempt *websocket.Attempt, eventTime time.Time) { + color := ansi.Color(os.Stdout) + localTime := eventTime.Format(interactiveTimeLayout) + + pendingStr := fmt.Sprintf("%s [%s] %s %s %s", + color.Faint(localTime), + color.Faint("..."), + attempt.Body.Request.Method, + fmt.Sprintf("http://localhost%s", attempt.Body.Path), + color.Faint("(Waiting for response)"), + ) + + event := tui.EventInfo{ + ID: eventID, + AttemptID: attempt.Body.AttemptId, + Status: 0, + Success: false, + Time: eventTime, + Data: attempt, + LogLine: pendingStr, + ResponseStatus: 0, + ResponseDuration: 0, + } + + if r.teaProgram != nil { + r.teaProgram.Send(tui.NewEventMsg{Event: event}) + } +} + +// OnEventError is called when an event encounters an error +func (r *InteractiveRenderer) OnEventError(eventID string, attempt *websocket.Attempt, err error, startTime time.Time) { + color := ansi.Color(os.Stdout) + localTime := time.Now().Format(interactiveTimeLayout) + + errStr := fmt.Sprintf("%s [%s] Failed to %s: %v", + color.Faint(localTime), + color.Red("ERROR").Bold(), + attempt.Body.Request.Method, + err, + ) + + event := tui.EventInfo{ + ID: eventID, + AttemptID: attempt.Body.AttemptId, + Status: 0, + Success: false, + Time: time.Now(), + Data: attempt, + LogLine: errStr, + ResponseStatus: 0, + ResponseDuration: 0, + } + + if r.teaProgram != nil { + r.teaProgram.Send(tui.NewEventMsg{Event: event}) + } +} + +// OnConnectionWarning is called when approaching connection limits +func (r *InteractiveRenderer) OnConnectionWarning(activeRequests int32, maxConns int) { + // In interactive mode, warnings could be shown in TUI + // For now, just log it + log.WithField("prefix", "proxy.InteractiveRenderer"). + Warnf("High connection load: %d active requests (limit: %d)", activeRequests, maxConns) +} + +// Cleanup gracefully stops the TUI and restores terminal +func (r *InteractiveRenderer) Cleanup() { + if r.teaProgram != nil { + r.teaProgram.Quit() + // Wait a moment for graceful shutdown + select { + case <-r.doneCh: + // TUI exited cleanly + case <-time.After(100 * time.Millisecond): + // Timeout, force kill + r.teaProgram.Kill() + } + // Give terminal a moment to fully restore after alt screen exit + time.Sleep(50 * time.Millisecond) + } +} + +// Done returns a channel that is closed when the renderer wants to quit +func (r *InteractiveRenderer) Done() <-chan struct{} { + return r.doneCh +} diff --git a/pkg/proxy/renderer_simple.go b/pkg/proxy/renderer_simple.go new file mode 100644 index 0000000..15a85ef --- /dev/null +++ b/pkg/proxy/renderer_simple.go @@ -0,0 +1,156 @@ +package proxy + +import ( + "fmt" + "os" + "time" + + "github.com/briandowns/spinner" + log "github.com/sirupsen/logrus" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/websocket" +) + +const simpleTimeLayout = "2006-01-02 15:04:05" + +// SimpleRenderer renders events to stdout for compact and quiet modes +type SimpleRenderer struct { + cfg *RendererConfig + quietMode bool + doneCh chan struct{} + spinner *spinner.Spinner + hasConnected bool // Track if we've successfully connected at least once + isReconnecting bool // Track if we're currently in reconnection mode +} + +// NewSimpleRenderer creates a new simple renderer +func NewSimpleRenderer(cfg *RendererConfig, quietMode bool) *SimpleRenderer { + return &SimpleRenderer{ + cfg: cfg, + quietMode: quietMode, + doneCh: make(chan struct{}), + } +} + +// OnConnecting is called when starting to connect +func (r *SimpleRenderer) OnConnecting() { + r.spinner = ansi.StartNewSpinner("Getting ready...", log.StandardLogger().Out) +} + +// OnConnected is called when websocket connects +func (r *SimpleRenderer) OnConnected() { + r.hasConnected = true + r.isReconnecting = false // Reset reconnection state + if r.spinner != nil { + ansi.StopSpinner(r.spinner, "", log.StandardLogger().Out) + r.spinner = nil + color := ansi.Color(os.Stdout) + fmt.Printf("%s\n\n", color.Faint("Connected. Waiting for events...")) + } +} + +// OnDisconnected is called when websocket disconnects +func (r *SimpleRenderer) OnDisconnected() { + // Only show "Connection lost" if we've successfully connected before + if r.hasConnected && !r.isReconnecting { + // First disconnection - print newline for visual separation + fmt.Println() + // Stop any existing spinner first + if r.spinner != nil { + ansi.StopSpinner(r.spinner, "", log.StandardLogger().Out) + } + // Start new spinner with reconnection message + r.spinner = ansi.StartNewSpinner("Connection lost, reconnecting...", log.StandardLogger().Out) + r.isReconnecting = true + } + // If we haven't connected yet, the "Getting ready..." spinner is still showing + // If already reconnecting, the spinner is already showing +} + +// OnError is called when an error occurs +func (r *SimpleRenderer) OnError(err error) { + color := ansi.Color(os.Stdout) + fmt.Printf("%s %v\n", color.Red("ERROR:"), err) +} + +// OnEventPending is called when an event starts (not used in simple renderer) +func (r *SimpleRenderer) OnEventPending(eventID string, attempt *websocket.Attempt, startTime time.Time) { + // Simple renderer doesn't show pending events +} + +// OnEventComplete is called when an event completes successfully +func (r *SimpleRenderer) OnEventComplete(eventID string, attempt *websocket.Attempt, response *EventResponse, startTime time.Time) { + localTime := time.Now().Format(simpleTimeLayout) + color := ansi.Color(os.Stdout) + + // Build display URL + var displayURL string + if r.cfg.ProjectMode == "console" { + displayURL = r.cfg.ConsoleBaseURL + "/?event_id=" + eventID + } else { + displayURL = r.cfg.DashboardBaseURL + "/events/" + eventID + } + + durationMs := response.Duration.Milliseconds() + + outputStr := fmt.Sprintf("%s [%d] %s %s %s %s %s", + color.Faint(localTime), + ansi.ColorizeStatus(response.StatusCode), + attempt.Body.Request.Method, + r.cfg.TargetURL.Scheme+"://"+r.cfg.TargetURL.Host+r.cfg.TargetURL.Path+attempt.Body.Path, + color.Faint(fmt.Sprintf("(%dms)", durationMs)), + color.Faint("→"), + color.Faint(displayURL), + ) + + // In quiet mode, only print fatal errors + if r.quietMode { + // Only show if it's a fatal error (status 0 means connection error) + if response.StatusCode == 0 { + fmt.Println(outputStr) + } + } else { + // Compact mode: print everything + fmt.Println(outputStr) + } +} + +// OnEventError is called when an event encounters an error +func (r *SimpleRenderer) OnEventError(eventID string, attempt *websocket.Attempt, err error, startTime time.Time) { + color := ansi.Color(os.Stdout) + localTime := time.Now().Format(simpleTimeLayout) + + errStr := fmt.Sprintf("%s [%s] Failed to %s: %v", + color.Faint(localTime), + color.Red("ERROR").Bold(), + attempt.Body.Request.Method, + err, + ) + + // Always print errors (both compact and quiet modes show errors) + fmt.Println(errStr) +} + +// OnConnectionWarning is called when approaching connection limits +func (r *SimpleRenderer) OnConnectionWarning(activeRequests int32, maxConns int) { + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s High connection load detected (%d active requests)\n", + color.Yellow("⚠ WARNING:"), activeRequests) + fmt.Printf(" The CLI is limited to %d concurrent connections per host.\n", maxConns) + fmt.Printf(" Consider reducing request rate or increasing connection limit.\n") + fmt.Printf(" Run with --max-connections=%d to increase the limit.\n\n", maxConns*2) +} + +// Cleanup stops the spinner and cleans up resources +func (r *SimpleRenderer) Cleanup() { + if r.spinner != nil { + ansi.StopSpinner(r.spinner, "", log.StandardLogger().Out) + r.spinner = nil + } +} + +// Done returns a channel that is closed when the renderer wants to quit +func (r *SimpleRenderer) Done() <-chan struct{} { + return r.doneCh +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go index fef4018..bd65514 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -15,7 +15,7 @@ import ( ) const ( - maxEvents = 1000 // Maximum events to keep in memory (all navigable) + maxEvents = 1000 // Maximum events to keep in memory (all navigable) timeLayout = "2006-01-02 15:04:05" // Time format for display ) @@ -100,7 +100,9 @@ func (m Model) Init() tea.Cmd { // AddEvent adds a new event to the history func (m *Model) AddEvent(event EventInfo) { - // Check for duplicates (same ID and timestamp) + // Check for duplicates using Time + EventID + // This allows the same event to appear multiple times if retried at different times + // while preventing true duplicates from the same moment for i := len(m.events) - 1; i >= 0; i-- { if m.events[i].ID == event.ID && m.events[i].Time.Equal(event.Time) { return // Duplicate, skip @@ -147,9 +149,9 @@ func (m *Model) AddEvent(event EventInfo) { } } -// UpdateEvent updates an existing event by EventID + Time +// UpdateEvent updates an existing event by EventID + Time, or creates a new one if not found func (m *Model) UpdateEvent(update UpdateEventMsg) { - // Find event by EventID + Time (unique identifier for retries) + // Find event by EventID + Time (same uniqueness criteria as AddEvent) for i := range m.events { if m.events[i].ID == update.EventID && m.events[i].Time.Equal(update.Time) { // Update event fields @@ -163,6 +165,23 @@ func (m *Model) UpdateEvent(update UpdateEventMsg) { return } } + + // Event not found (response came back in < 100ms, so pending event was never created) + // Create a new event with the complete data + newEvent := EventInfo{ + ID: update.EventID, + AttemptID: update.AttemptID, + Status: update.Status, + Success: update.Success, + Time: update.Time, + Data: update.Data, + LogLine: update.LogLine, + ResponseStatus: update.ResponseStatus, + ResponseHeaders: update.ResponseHeaders, + ResponseBody: update.ResponseBody, + ResponseDuration: update.ResponseDuration, + } + m.AddEvent(newEvent) } // Navigate moves selection up or down (all events are navigable) @@ -371,8 +390,10 @@ type NewEventMsg struct { // UpdateEventMsg is sent when an existing event gets a response type UpdateEventMsg struct { - EventID string // Event ID - Time time.Time // Time when event was received (unique with EventID) + EventID string // Event ID from Hookdeck + AttemptID string // Attempt ID (unique per connection) + Time time.Time // Event time + Data *websocket.Attempt // Full attempt data Status int Success bool LogLine string diff --git a/pkg/tui/update.go b/pkg/tui/update.go index 9dc8687..c90cfa0 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -70,7 +70,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case retryResultMsg: - // Retry completed, could show notification if needed + // Retry completed (new attempt will arrive via websocket as a new event) return m, nil case openBrowserResultMsg: @@ -133,7 +133,7 @@ func (m Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "r", "R": - // Retry selected event + // Retry selected event (new attempt will arrive via websocket) return m, m.retrySelectedEvent() case "o", "O": From e636598d4f05a2c64c04ab737e3524480d04ebf6 Mon Sep 17 00:00:00 2001 From: Alexandre Bouchard Date: Sun, 12 Oct 2025 23:17:14 -0400 Subject: [PATCH 22/22] chore: Fix data display --- pkg/tui/model.go | 62 +++++++++++++++++++----------------------------- pkg/tui/view.go | 31 ++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 39 deletions(-) diff --git a/pkg/tui/model.go b/pkg/tui/model.go index bd65514..7d8e420 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -271,95 +271,83 @@ func (m *Model) calculateHeaderHeight(header string) int { func (m *Model) buildDetailsContent(event *EventInfo) string { var content strings.Builder - content.WriteString("═══════════════════════════════════════════════════════════════\n") - content.WriteString(" EVENT DETAILS\n") - content.WriteString("═══════════════════════════════════════════════════════════════\n\n") - - // Event metadata - content.WriteString(faintStyle.Render("Event ID: ") + event.ID + "\n") - content.WriteString(faintStyle.Render("Time: ") + event.Time.Format(timeLayout) + "\n") + content.WriteString(faintStyle.Render("[d] Return to event list • [↑↓] Scroll • [PgUp/PgDn] Page")) + content.WriteString("\n\n") + + // Event metadata - compact single line format + var metadataLine strings.Builder + metadataLine.WriteString(event.ID) + metadataLine.WriteString(" • ") + metadataLine.WriteString(event.Time.Format(timeLayout)) if event.ResponseDuration > 0 { - content.WriteString(faintStyle.Render("Duration: ") + event.ResponseDuration.String() + "\n") + metadataLine.WriteString(" • ") + metadataLine.WriteString(event.ResponseDuration.String()) } + content.WriteString(metadataLine.String()) content.WriteString("\n") + content.WriteString(faintStyle.Render(strings.Repeat("─", 63))) + content.WriteString("\n\n") // Request section if event.Data != nil { - content.WriteString("─────────────────────────────────────────────────────────────\n") - content.WriteString(" REQUEST\n") - content.WriteString("─────────────────────────────────────────────────────────────\n\n") + content.WriteString(boldStyle.Render("Request")) + content.WriteString("\n\n") - content.WriteString(faintStyle.Render("Method: ") + event.Data.Body.Request.Method + "\n") - content.WriteString(faintStyle.Render("Path: ") + event.Data.Body.Path + "\n\n") + // HTTP request line: METHOD URL + requestURL := m.cfg.TargetURL.Scheme + "://" + m.cfg.TargetURL.Host + event.Data.Body.Path + content.WriteString(event.Data.Body.Request.Method + " " + requestURL + "\n\n") // Request headers - content.WriteString("Headers:\n") if len(event.Data.Body.Request.Headers) > 0 { // Parse headers JSON var headers map[string]string if err := json.Unmarshal(event.Data.Body.Request.Headers, &headers); err == nil { for key, value := range headers { - content.WriteString(" " + faintStyle.Render(key+":") + " " + value + "\n") + content.WriteString(faintStyle.Render(key+": ") + value + "\n") } } else { - content.WriteString(" " + string(event.Data.Body.Request.Headers) + "\n") + content.WriteString(string(event.Data.Body.Request.Headers) + "\n") } - } else { - content.WriteString(" " + faintStyle.Render("(none)") + "\n") } content.WriteString("\n") // Request body - content.WriteString("Body:\n") if event.Data.Body.Request.DataString != "" { // Try to pretty print JSON prettyBody := m.prettyPrintJSON(event.Data.Body.Request.DataString) content.WriteString(prettyBody + "\n") - } else { - content.WriteString(" " + faintStyle.Render("(empty)") + "\n") } content.WriteString("\n") } // Response section - content.WriteString("─────────────────────────────────────────────────────────────\n") - content.WriteString(" RESPONSE\n") - content.WriteString("─────────────────────────────────────────────────────────────\n\n") + content.WriteString(boldStyle.Render("Response")) + content.WriteString("\n\n") if event.ResponseStatus > 0 { - content.WriteString(faintStyle.Render("Status: ") + fmt.Sprintf("%d", event.ResponseStatus) + "\n\n") + // HTTP status line + content.WriteString(fmt.Sprintf("%d", event.ResponseStatus) + "\n\n") // Response headers - content.WriteString("Headers:\n") if len(event.ResponseHeaders) > 0 { for key, values := range event.ResponseHeaders { for _, value := range values { - content.WriteString(" " + faintStyle.Render(key+":") + " " + value + "\n") + content.WriteString(faintStyle.Render(key+": ") + value + "\n") } } - } else { - content.WriteString(" " + faintStyle.Render("(none)") + "\n") } content.WriteString("\n") // Response body - content.WriteString("Body:\n") if event.ResponseBody != "" { // Try to pretty print JSON prettyBody := m.prettyPrintJSON(event.ResponseBody) content.WriteString(prettyBody + "\n") - } else { - content.WriteString(" " + faintStyle.Render("(empty)") + "\n") } } else { content.WriteString(faintStyle.Render("(No response received yet)") + "\n") } - content.WriteString("\n") - content.WriteString("═══════════════════════════════════════════════════════════════\n") - content.WriteString("Press " + boldStyle.Render("[d]") + " or " + boldStyle.Render("[ESC]") + " to close • " + boldStyle.Render("[↑↓]") + " to scroll\n") - content.WriteString("═══════════════════════════════════════════════════════════════\n") - return content.String() } diff --git a/pkg/tui/view.go b/pkg/tui/view.go index e706b33..c68bb5e 100644 --- a/pkg/tui/view.go +++ b/pkg/tui/view.go @@ -13,9 +13,9 @@ func (m Model) View() string { return "" } - // If showing details, render full-screen details view + // If showing details, render full-screen details view with action bar if m.showingDetails { - return m.detailsViewport.View() + return m.renderDetailsView() } // Build fixed header (connection info + events title + divider) @@ -154,6 +154,33 @@ func (m Model) renderEventHistory() string { return s.String() } +// renderDetailsView renders the details view with action bar at bottom +func (m Model) renderDetailsView() string { + // Calculate space for action bar (divider + action bar = 2 lines) + viewportHeight := m.height - 2 + if viewportHeight < 1 { + viewportHeight = 1 + } + m.detailsViewport.Height = viewportHeight + + var output strings.Builder + + // Viewport content (scrollable) + output.WriteString(m.detailsViewport.View()) + output.WriteString("\n") + + // Divider line + divider := strings.Repeat("─", m.width) + output.WriteString(dividerStyle.Render(divider)) + output.WriteString("\n") + + // Action bar - LAST line, no trailing newline + actionBar := "[d] Return to event list • [↑↓] Scroll • [PgUp/PgDn] Page" + output.WriteString(statusBarStyle.Render(actionBar)) + + return output.String() +} + // renderStatusBar renders the bottom status bar with keyboard shortcuts func (m Model) renderStatusBar() string { selectedEvent := m.GetSelectedEvent()