Skip to content

Conversation

alexbouchardd
Copy link
Contributor

No description provided.

@alexluong
Copy link
Collaborator

alexluong commented Oct 9, 2025

Hi @alexbouchardd, appreciate the initiative and great work. Still testing it out, gonna track this document for a few issues or DX considerations.

Testing done in iTerm, using zsh.

Open in dashboard

This is not related to this but worth a note I think. The first hiccup I ran into was that my CLI is using a different project from the one on the dashboard. So when it opens the event link, because the URL doesn't contain the project identifier, I got stuck and it didn't load.

Issues rendering & navigating events

CleanShot.2025-10-09.at.11.37.56.mp4

I discovered the issue was related to window/pane size. This issue only happened when I was in split pane, or when I set the terminal window at half-screen.

One follow-up is that the rendering is based on the view port size. It may lead to poor UI when the viewport changes (change number of pane or change terminal window size from full screen to half screen or vice versa).

CleanShot 2025-10-09 at 11 54 59

We can consider using a dedicated package for TUI rendering such as bubbletea or tview.

UX: Layout shift while navigating event list

CleanShot.2025-10-09.at.16.25.47.mp4

Because we're adding the caret > to the selected event line, it's shifting the layout of that line and the previously selected one. I'm not 100% sure if that's an intentional UX to make that line stands out.

An idea is to allocate whitespace for the caret, and if we'd like to make the line stand out then maybe we can slightly dim the unselected events to keep the selected line stand out better.

Just a UX thing, not a huge concern.

UX: Exit event data screen inconsistency

My understanding is we can d to show data. From that screen, we can q to exit. However, my experience is that it requires the user to scroll a bit first before q will exit. Not sure if that's intentional.

CleanShot.2025-10-09.at.16.32.13.mp4

UX: terminal history

I started a new terminal tab, started the CLI, played around with it, then exited. It left a pretty poor terminal state afterwards, taking over the history of the terminal before starting the session.

It's not a big deal, but I think a better approach of running the session in an "alternate canvas", similar to the Show Data UI, would be a better DX.

CleanShot.2025-10-09.at.16.43.58.mp4

still WIP, will continue adding more to this comment

Overall I really like the new experience! Amazing job on leading this effort. The ability to inspect & retry is very nice.

I think the main concern is the rendering aspect. If this experience works consistently then it's a great experience. But I'm not 100% sure if it will work across the board yet. Because of that, I think it may make sense if we make this an opt-in experience via --interactive or something like that. We can certainly add a callout in the default mode for users to try out if they're interested.


As for the PR, I only skimmed the code and asked Claude to review. Nothing stood out, and things seem to work correctly. Happy to move it forward and I personally would love to spend more time on this myself when I have the chance.

@leggetter
Copy link
Collaborator

As per @alexluong comment, should the team ID param be present in all links to ensure the correct project is opened?

Note: I still think the org and project should be in the URL path.

@alexluong
Copy link
Collaborator

Note: I still think the org and project should be in the URL path.

I concur.

@alexluong
Copy link
Collaborator

PR review from Claude, I think some of these can be valid

PR Review: Interactive Keyboard Shortcuts and Output Mode Controls

Summary

This PR adds interactive keyboard shortcuts and output mode controls to the hookdeck listen command. The main feature is a full terminal UI with event history navigation, retry/open/detail actions, and configurable verbosity levels.

Overall Assessment

Architecture: ✅ Good separation of concerns
Functionality: ✅ Feature-complete and well-thought-out
Reliability: ⚠️ Several edge cases and race conditions
UX: ✅ Generally good, minor improvements needed
Code Quality: ✅ Clean and readable

Verdict: Solid foundation, but needs hardening before production use. The critical issues should be fixed before merging.


Critical Issues 🔴

1. Race Condition in Event Selection

Location: pkg/proxy/event_history.go

func (eh *EventHistory) GetSelectedEvent() *WebhookEvent {
    eh.mu.RLock()
    defer eh.mu.RUnlock()

    if eh.selectedIndex >= len(eh.events) {
        return nil
    }
    return eh.events[eh.selectedIndex]  // ❌ Index could be stale
}

Problem: Between checking the length and accessing the array, events could be added/removed by another goroutine. While you have a lock, selectedIndex could still be out of bounds if events are removed.

Scenario:

  1. User has 10 events, selectedIndex = 9
  2. User presses ↑ (now selectedIndex = 8)
  3. Simultaneously, event history is pruned to keep only last 5 events
  4. GetSelectedEvent() tries to access events[8] but length is now 5
  5. Panic: index out of range

Fix: Add bounds checking:

if eh.selectedIndex < 0 || eh.selectedIndex >= len(eh.events) {
    eh.selectedIndex = max(0, len(eh.events)-1)  // Reset to valid
}

2. Terminal State Not Restored on Panic

Location: pkg/proxy/terminal_ui.go

func (ui *TerminalUI) EnableRawMode() error {
    oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
    if err != nil {
        return err
    }
    ui.oldState = oldState
    return nil
}

Problem: If the program panics while in raw mode, the terminal is left broken (no echo, weird input handling). User has to type reset blindly.

Fix: Add a defer in the main proxy Run function:

func (p *Proxy) Run(ctx context.Context) error {
    if p.cfg.Output == "interactive" {
        p.ui.EnableRawMode()
        defer func() {
            if p.ui.oldState != nil {
                term.Restore(int(os.Stdin.Fd()), p.ui.oldState)
            }
        }()
    }
    // ... rest of code
}

3. Context Cancellation Doesn't Stop Keyboard Listener

Location: pkg/proxy/keyboard.go:47-67

go func() {
    for {
        select {
        case <-ctx.Done():
            return
        default:
            r, _, err := reader.ReadRune()  // ❌ BLOCKS HERE
            // ...
        }
    }
}()

Problem: When context is cancelled, the goroutine checks ctx.Done() but then immediately blocks on ReadRune(). If no key is pressed, it hangs forever.

Impact: Program won't exit cleanly when user presses Ctrl+C until they press another key.

Fix: This is hard to fix properly without closing stdin or using a timeout. One approach:

go func() {
    for {
        // Non-blocking channel check
        select {
        case <-ctx.Done():
            return
        default:
        }

        // Set read deadline (requires *os.File, not bufio)
        os.Stdin.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
        r, _, err := reader.ReadRune()

        if err != nil {
            if errors.Is(err, os.ErrDeadlineExceeded) {
                continue  // Timeout, check ctx.Done() again
            }
            return
        }
        // ... handle key
    }
}()

4. Terminal Resize Handling (Line Wrapping)

Location: pkg/proxy/terminal_ui.go:301-324

Problem: When terminal width changes, event lines wrap to multiple terminal lines, but the viewport calculation assumes one event = one line. This causes overlapping text and broken rendering.

Issues:

  • No line truncation to prevent wrapping
  • No SIGWINCH handler to re-render on resize
  • Viewport calculation doesn't account for wrapped lines

Fix:

  1. Truncate lines to terminal width:
func formatEventLine(event *WebhookEvent, isSelected bool, maxWidth int) string {
    // ... build line as before ...

    // Strip ANSI codes to measure actual display width
    displayWidth := len(ansi.StripANSI(line))

    if displayWidth > maxWidth {
        // Truncate and add ellipsis
        line = truncateWithANSI(line, maxWidth-3) + "..."
    }

    return line
}
  1. Add SIGWINCH handler:
import "os/signal"
import "syscall"

sigwinch := make(chan os.Signal, 1)
signal.Notify(sigwinch, syscall.SIGWINCH)

go func() {
    for {
        select {
        case <-ctx.Done():
            return
        case <-sigwinch:
            // Terminal was resized - trigger re-render
            ui.triggerRerender()
        }
    }
}()

High Priority Issues 🟡

5. HTTP Response Not Checked in Retry

Location: pkg/proxy/event_actions.go:72-84

resp, err := client.Post(context.Background(), retryURL, []byte("{}"), nil)
if err != nil {
    // ... log error
    return
}
defer resp.Body.Close()
// ❌ No check if resp.StatusCode >= 400

Problem: A 404 or 500 response is treated as success - no feedback to user.

Fix:

if err != nil || resp.StatusCode >= 400 {
    ea.ui.SafePrintf("[%s] Retry failed (status %d)\n",
        color.Red("ERROR").Bold(), resp.StatusCode)
    return
}
ea.ui.SafePrintf("[%s] Event retried successfully\n",
    color.Green("✓"))

6. No Feedback After Actions

Location: pkg/proxy/event_actions.go:83, 123

When user presses r or o, nothing confirms the action succeeded. Silent success is confusing.

Fix: Print status messages via SafePrintf.


7. Temporary File Cleanup Race

Location: pkg/proxy/event_actions.go:289-293

tmpfile, err := os.CreateTemp("", "hookdeck-event-*.txt")
// ...
defer os.Remove(tmpfile.Name())  // ❌ Removed too early

// ...
cmd := exec.Command("less", "-R", "-P?eEND:.", tmpfile.Name())
cmd.Stdout = tty
cmd.Stdin = tty
cmd.Stderr = tty
err = cmd.Run()  // ❌ File might not exist anymore!

Problem: defer removes the file, but then cmd.Run() tries to open it. On fast systems, less might not have opened the file yet.

Fix: Remove file after cmd.Run():

tmpfile, err := os.CreateTemp("", "hookdeck-event-*.txt")
// ... write content, close file

cmd := exec.Command("less", "-R", "-P?eEND:.", tmpfile.Name())
// ... setup stdin/stdout/stderr
err = cmd.Run()

os.Remove(tmpfile.Name())  // Now safe to remove

8. No Maximum Event History Limit

Location: pkg/proxy/event_history.go:30-44

func (eh *EventHistory) AddEvent(event *WebhookEvent) {
    eh.mu.Lock()
    defer eh.mu.Unlock()

    eh.events = append(eh.events, event)  // ❌ Unbounded growth
    eh.selectedIndex = len(eh.events) - 1
}

Problem: In a long-running session with thousands of events, memory grows indefinitely.

Fix: Keep only last N events:

const maxEventHistory = 100

func (eh *EventHistory) AddEvent(event *WebhookEvent) {
    eh.mu.Lock()
    defer eh.mu.Unlock()

    eh.events = append(eh.events, event)

    // Prune old events
    if len(eh.events) > maxEventHistory {
        eh.events = eh.events[len(eh.events)-maxEventHistory:]
        // Adjust selectedIndex if needed
        if eh.selectedIndex >= len(eh.events) {
            eh.selectedIndex = len(eh.events) - 1
        }
    }

    eh.selectedIndex = len(eh.events) - 1
}

Medium Priority Issues 🟢

9. Error Handling in Browser Opening

Location: pkg/proxy/event_actions.go:127-142

User gets an error log but no actionable message. Should print the URL so they can open it manually.

Fix:

if err := exec.Command(cmd, args...).Start(); err != nil {
    ea.ui.SafePrintf("[%s] Couldn't open browser. Visit: %s\n",
        color.Yellow("WARN"), eventURL)
    return
}

10. JSON Unmarshaling Silently Fails

Location: pkg/proxy/event_actions.go:174-188

If headers aren't valid JSON, they're just not shown. Should fall back to raw display.

Fix:

if err := json.Unmarshal(...); err == nil {
    // Pretty print
} else {
    content.WriteString("(raw) " + string(webhookEvent.Body.Request.Headers))
}

11. No Validation of Event Data

Location: pkg/proxy/event_actions.go:146-271

ShowEventDetails() assumes webhookEvent.Body.Request.Headers exists and is well-formed. Could panic on malformed data.

Fix: Add nil checks before accessing nested fields.


12. Platform-Specific Code Without Graceful Fallback

Location: pkg/proxy/event_actions.go

tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)

This is Unix/Linux/macOS only. On Windows, /dev/tty doesn't exist. Should check runtime.GOOS or handle the error.


13. Status Line Calculation Doesn't Account for Long Messages

Location: pkg/proxy/terminal_ui.go

func (ui *TerminalUI) updateStatusLine(message string) {
    width, height, _ := term.GetSize(int(os.Stdin.Fd()))

    fmt.Fprintf(os.Stdout, "\033[%d;0H", height)
    // ... prints message
}

If message is longer than width, it wraps and pushes content up. Should truncate.


14. Pollutes Scrollback Buffer

The application leaves rendering artifacts in terminal history after exit. This makes it difficult to see what was in the terminal before the CLI session.

Better behavior: Use alternate screen buffer (like vim, less, top do):

// On startup
fmt.Print("\x1b[?1049h")  // Switch to alternate screen
fmt.Print("\x1b[?25l")    // Hide cursor

// On exit
fmt.Print("\x1b[?25h")    // Show cursor
fmt.Print("\x1b[?1049l")  // Return to main screen

Design/Logic Issues 🔵

15. selectedIndex Always Points to Latest Event

Location: pkg/proxy/event_history.go

func (eh *EventHistory) AddEvent(event *WebhookEvent) {
    // ...
    eh.selectedIndex = len(eh.events) - 1  // ❌ Moves selection
}

UX Problem: User navigates to event #5 to inspect it. New event arrives. Selection jumps to latest event. User loses their place.

Better UX: Keep selection on the event user chose, unless they explicitly move it or the selected event is pruned from history.


16. No Visual Indicator of Scroll Position

When there are 100 events but only 10 visible, user has no idea where they are in the list (e.g., "showing 50-60 of 100").

Suggestion: Add to status line: Events: 50-60 of 100


17. Keyboard Shortcuts Not Discoverable

If user misses the status line or it scrolls away, they don't know what keys do what.

Suggestion: Add help screen (h or ? key) showing all shortcuts.


18. Connection Creation Message Not in Interactive Flow

Location: pkg/listen/connection.go

fmt.Printf("\nThere's no CLI destination connected to %s, creating one named %s\n", ...)

This prints during setup, before interactive mode starts. In interactive mode, it clutters the event area.


Code Quality Issues 📝

19. Magic Numbers

  • height - 3 (line 149) - What's 3? Status line + padding?
  • 10 in "last 10 events" (README) - Not enforced in code
  • maxWidth - 2 - What's 2?

Should be named constants.


20. Duplicated Terminal Size Queries

term.GetSize() is called in multiple places. Could be cached and updated on SIGWINCH.


21. No Tests

For a complex interactive feature, there should be unit tests for:

  • Event history management
  • Viewport calculations
  • Selection navigation
  • Line truncation logic

Recommendations

Must Fix Before Merge:

  1. Terminal state restoration on panic (feat: logout command & creating guest access when calling listen #2)
  2. Event selection race condition (fix: taking cli_path from attempt request instead of destination #1)
  3. Terminal resize handling (fix: Fix to send data in string_data in case the content-type is text/plain or x-wwww-form-urlencoded #4)
  4. Unbounded memory growth (Fix: add content length #8)

Should Fix Before Merge:

  1. Context cancellation cleanup (Feat/unauthenticated user #3)
  2. HTTP response validation (docker login #5)
  3. Temporary file cleanup race (fix: Added content-length header #7)
  4. User feedback for actions (fix: Always use DataString instead of data #6)

Nice to Have:

  • All medium priority issues
  • Design improvements (scroll position indicator, help screen)
  • Tests for core functionality

Testing Recommendations

Since this is a large change affecting terminal I/O, test:

  1. Terminal compatibility: iTerm2, Terminal.app, Alacritty, tmux, screen
  2. Edge cases:
    • Rapid key presses (especially arrow keys)
    • Resizing terminal during operation
    • Large number of events (performance)
    • Events arriving while viewing details
  3. Output modes: Ensure --output compact and --output quiet work correctly
  4. Graceful degradation: What happens if terminal doesn't support raw mode?
  5. Long-running sessions: Memory usage over time
  6. Platform testing: macOS, Linux, Windows (if supported)

Positive Highlights

  • ✅ Well-structured separation of concerns (event_actions.go, event_history.go, keyboard.go, terminal_ui.go)
  • ✅ Comprehensive documentation in README with examples
  • ✅ Thoughtful UX with status line, visual indicators, and keyboard shortcuts
  • ✅ Backward compatible with new --output flag (defaults to interactive)
  • ✅ Guest account improvements with persistent URL storage
  • ✅ Good use of mutexes for thread safety (just needs bounds checking)
  • ✅ Clean, readable code that's easy to follow

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants