From 752b0cd2740d7b3d53e5dfa6e48dadc545a2608c Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 11 May 2026 22:18:37 -0700 Subject: [PATCH 1/7] spec(tui): add hooksctl-tui openspec change artifacts Proposal, design, spec, and tasks for the full-screen Bubble Tea status dashboard for `hooksctl forward`. --- openspec/changes/hooksctl-tui/.openspec.yaml | 2 + openspec/changes/hooksctl-tui/design.md | 84 +++++++++ openspec/changes/hooksctl-tui/proposal.md | 30 +++ .../hooksctl-tui/specs/forward-tui/spec.md | 175 ++++++++++++++++++ openspec/changes/hooksctl-tui/tasks.md | 85 +++++++++ 5 files changed, 376 insertions(+) create mode 100644 openspec/changes/hooksctl-tui/.openspec.yaml create mode 100644 openspec/changes/hooksctl-tui/design.md create mode 100644 openspec/changes/hooksctl-tui/proposal.md create mode 100644 openspec/changes/hooksctl-tui/specs/forward-tui/spec.md create mode 100644 openspec/changes/hooksctl-tui/tasks.md diff --git a/openspec/changes/hooksctl-tui/.openspec.yaml b/openspec/changes/hooksctl-tui/.openspec.yaml new file mode 100644 index 0000000..40cc12f --- /dev/null +++ b/openspec/changes/hooksctl-tui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-12 diff --git a/openspec/changes/hooksctl-tui/design.md b/openspec/changes/hooksctl-tui/design.md new file mode 100644 index 0000000..fc14728 --- /dev/null +++ b/openspec/changes/hooksctl-tui/design.md @@ -0,0 +1,84 @@ +## Context + +`hooksctl forward` runs an SSE loop against `/subscribe/`, deserializes events, and pushes them to a local HTTP target. Today the command emits structured log lines to stdout. Operators have no live view of delivery success/failure, latency, or retry state. + +The design spec (`hooksctl-tui-spec.html`) defines a single full-screen status-dashboard TUI: identity header, scrollable delivery tail, persistent keybind bar. Framework mandated: Bubble Tea + Bubbles + Lip Gloss. Minimum terminal: 80×24. + +Constraints: +- No server-side changes. The TUI is a presentation layer over the existing SSE stream. +- golangci-lint + `go test -race` must stay green. +- The new `internal/tui` package must not import any existing `internal/*` package that itself imports the store or token machinery (keep the dependency graph clean). + +## Goals / Non-Goals + +**Goals:** +- Live, full-screen TUI for `hooksctl forward` when stdout is a TTY. +- Ring-buffered delivery log (cap 500) with per-row: timestamp, method, path, source, status, latency, size, suffix. +- Session header: online/reconnecting/paused/offline pill, reconnect count, uptime ticker, account email, forwarding route, token fingerprint. +- Responsive layout: column dropping below 80 cols; identity collapse below 24 rows. +- Keybinds: copy URL (`c`), open web UI (`w`), replay last (`r`), pause/resume (`p`), help overlay (`?`), graceful quit (`q`/`^C`). +- Graceful quit: first press drains in-flight; second force-quits. + +**Non-Goals:** +- Per-delivery detail pane (body, headers, JSON view) — out of scope for v1. +- Filtering beyond pause. +- Multi-route sessions. +- Persistent scrollback across restarts. +- Non-TTY fallback changes (existing log output stays as-is). + +## Decisions + +### 1. New `internal/tui` package, not inlined in `cmd/hooksctl` + +Keeps the model/update/view testable without pulling in CLI flag parsing. The `cmd/hooksctl/forward.go` command detects `term.IsTerminal(os.Stdout.Fd())` and hands a channel of `tui.DeliveryEvent` to `tui.New(...)`, then runs `tea.NewProgram(model, tea.WithAltScreen())`. + +Alternatives considered: inlining in `cmd/hooksctl` (harder to unit-test), making it a sub-package of `cmd` (circular with shared types). + +### 2. Bubble Tea message types bridge the SSE goroutine + +The existing SSE consumer goroutine sends `tui.DeliveryReceivedMsg` and `tui.DeliveryCompletedMsg` via `tea.Program.Send(msg)`. This keeps the SSE loop outside the Bubble Tea event loop and avoids blocking the update cycle on network I/O. + +Alternatives considered: running SSE inside a `tea.Cmd` — awkward because SSE is a long-lived connection, not a one-shot command. + +### 3. Ring buffer in the model, `tea/viewport` for scrolling + +`deliveries []Delivery` is capped at 500 via modular append. `viewport.Model` from `github.com/charmbracelet/bubbles` owns scroll position and is fed a pre-rendered string on every update. Sticky-to-bottom flag (`atBottom bool`) is set to false on any manual scroll key and restored on `G`/end. + +Alternatives considered: rendering directly from a slice without viewport (manual scroll math, more error-prone). + +### 4. Lip Gloss styles defined at package init, not per-render + +`var styles = newStyles()` is called once at startup using `lipgloss.HasDarkBackground`. On `tea.BackgroundColorMsg` the styles are rebuilt. This avoids re-allocating `lipgloss.Style` objects on every frame. + +Alternatives considered: passing renderer to each function (Lip Gloss v2 encourages this but Lip Gloss v1 is simpler and already in the ecosystem; pin v1 for now). + +### 5. `github.com/atotto/clipboard` for copy-URL + +Cross-platform clipboard access. Wrapped in a `tea.Cmd` so it doesn't block the update loop. On success fires `clipboardCopiedMsg` which shows a 1.5 s toast. + +### 6. TTY detection gates TUI entry + +`cmd/hooksctl/forward.go` checks `golang.org/x/term`.`IsTerminal(int(os.Stdout.Fd()))`. If false, falls back to existing structured-log output unchanged. This means CI/pipe usage is unaffected. + +## Risks / Trade-offs + +- **Terminal color support variance** → Mitigation: use `lipgloss.AdaptiveColor` with both light and dark hex values; Lip Gloss negotiates the color profile automatically. +- **Clipboard unavailable in some environments (headless, WSL without clip.exe)** → Mitigation: wrap clipboard write in error check; on failure, show toast "copy failed — no clipboard" instead of crashing. +- **Viewport height calculation off-by-one on resize** → Mitigation: derive height formula once in a named function `viewportHeight(termH int) int` and test it independently. +- **SSE reconnect during TUI session** → The existing SSE consumer already reconnects; it fires `sessionStateMsg{State: Reconnecting}` so the header pill updates. Deliveries during reconnect gap are missed (same as current behavior). +- **`tea.WithAltScreen()` leaves alternate screen on panic** → Mitigation: `defer p.RestoreTerminal()` in the command handler; same pattern used by k9s, lazygit. + +## Migration Plan + +1. Add dependencies to `go.mod` / `go.sum` (`go get`). +2. Implement `internal/tui` package (model, styles, messages). +3. Wire `cmd/hooksctl/forward.go` to detect TTY and launch TUI. +4. Manual smoke test: `make dev` + `hooksctl forward render` in a real terminal. +5. `make lint && make test` must pass before merge. + +Rollback: revert the TTY-detection branch in `forward.go`; the rest of the code is additive. + +## Open Questions + +- **Should `hooksctl tail` also get TUI treatment?** Tail is read-only and simpler — leave for v2. +- **Lip Gloss v1 vs v2?** v2 is in beta; stick with stable v1 (`v0.x`) for now and migrate after GA. diff --git a/openspec/changes/hooksctl-tui/proposal.md b/openspec/changes/hooksctl-tui/proposal.md new file mode 100644 index 0000000..c4b79ff --- /dev/null +++ b/openspec/changes/hooksctl-tui/proposal.md @@ -0,0 +1,30 @@ +## Why + +`hooksctl forward` currently produces no real-time feedback — operators have no visibility into webhook delivery status, latency, or errors while forwarding is active. This adds a full-screen TUI (terminal user interface) to the `forward` subcommand, bringing ngrok-style live observability to webhook relay sessions. + +## What Changes + +- **New `internal/tui` package** — Bubble Tea model + update + view for the `forward` session dashboard. +- **`hooksctl forward` gains a TUI mode** — the command boots into the full-screen dashboard instead of logging to stdout when stdout is a TTY. +- **Ring-buffered delivery log** — up to 500 deliveries displayed with timestamp, method, path, source, status, latency, size, and optional suffix (retry N/M, error label). +- **Live session header** — shows session state (online/reconnecting/paused/offline), reconnect count, uptime, account email, forwarding route, and token fingerprint. +- **Keybind bar** — persistent footer: copy forwarding URL, open web UI, replay last delivery, pause/resume, help overlay, quit. +- **Graceful quit** — first `q`/`^C` drains in-flight deliveries; second press force-quits. +- **Responsive layout** — columns drop right-to-left (suffix → size → latency) below 80 cols; identity block collapses to two lines below 24 rows. + +## Capabilities + +### New Capabilities + +- `forward-tui`: Full-screen Bubble Tea dashboard for the `hooksctl forward` session — live delivery tail, session header, keybind bar, help overlay, clipboard integration, and resize handling. + +### Modified Capabilities + +_(none — the existing `forward` HTTP/SSE logic is unchanged; the TUI is wired on top)_ + +## Impact + +- **New dependency**: `github.com/charmbracelet/bubbletea`, `github.com/charmbracelet/bubbles`, `github.com/charmbracelet/lipgloss`, `github.com/atotto/clipboard`. +- **`cmd/hooksctl`** — `forward` command detects TTY and hands off to the TUI model. +- **`internal/tui`** — new package; no changes to existing packages. +- **No server-side changes** — the TUI consumes the existing SSE `/subscribe` stream. diff --git a/openspec/changes/hooksctl-tui/specs/forward-tui/spec.md b/openspec/changes/hooksctl-tui/specs/forward-tui/spec.md new file mode 100644 index 0000000..2c84c1e --- /dev/null +++ b/openspec/changes/hooksctl-tui/specs/forward-tui/spec.md @@ -0,0 +1,175 @@ +## ADDED Requirements + +### Requirement: TUI launches on TTY +When `hooksctl forward` is invoked and stdout is a TTY, the command SHALL launch the full-screen Bubble Tea dashboard instead of emitting structured log lines. When stdout is not a TTY (pipe, redirect, CI), the existing log output SHALL be used unchanged. + +#### Scenario: TTY detected +- **WHEN** `hooksctl forward ` is run in an interactive terminal +- **THEN** the alternate screen activates and the full-screen TUI renders + +#### Scenario: Non-TTY stdout +- **WHEN** `hooksctl forward ` is run with stdout piped or redirected +- **THEN** structured log lines are emitted as before with no TUI + +--- + +### Requirement: Session header displays connection state +The TUI header SHALL display four rows of session metadata: (1) session state pill + reconnect count + uptime, (2) account email, (3) forwarding route, (4) token display. + +#### Scenario: Online state +- **WHEN** the SSE connection is established +- **THEN** the session pill reads `● online` in green and uptime ticks every second + +#### Scenario: Reconnecting state +- **WHEN** the SSE connection drops and reconnect is in progress +- **THEN** the session pill reads `● reconnecting` in amber and reconnect count increments + +#### Scenario: Paused state +- **WHEN** the user presses `p` +- **THEN** the session pill reads `● paused` in amber and inbound forwarding is queued + +#### Scenario: Token fingerprint display +- **WHEN** the TUI renders the token row +- **THEN** the token is shown as prefix + `…` + last 3 chars with scopes listed + +--- + +### Requirement: Live delivery tail +The TUI SHALL maintain a ring buffer of up to 500 delivery rows, newest at the bottom, auto-scrolling unless the user has manually scrolled up. + +#### Scenario: New delivery appended +- **WHEN** a `deliveryReceivedMsg` arrives +- **THEN** a new row is appended to the bottom of the delivery list and the viewport scrolls to bottom if `atBottom` is true + +#### Scenario: In-flight row updated +- **WHEN** a `deliveryCompletedMsg` arrives matching a pending in-flight row +- **THEN** the row's status, latency, and suffix fields are updated in-place and the `⇡ in flight` indicator is replaced with the final status code + +#### Scenario: Ring buffer cap +- **WHEN** the delivery buffer reaches 500 rows and a new delivery arrives +- **THEN** the oldest row is evicted and the new row is appended + +#### Scenario: User scrolls up +- **WHEN** the user presses an up/page-up scroll key +- **THEN** `atBottom` is set to false and new deliveries are appended without auto-scrolling + +--- + +### Requirement: Delivery row columns +Each delivery row SHALL render fixed-width columns in this order: timestamp (12), method (6), path (flex, min 12), source (18), status (4), latency (7), size (7), suffix (flex). Columns SHALL be color-coded per the design spec color tokens. + +#### Scenario: 2xx status color +- **WHEN** a delivery row has a 2xx HTTP status code +- **THEN** the status column renders in green (`#9FC26A`) + +#### Scenario: 4xx status color +- **WHEN** a delivery row has a 4xx HTTP status code +- **THEN** the status column renders in amber (`#E3B341`) + +#### Scenario: 5xx status color +- **WHEN** a delivery row has a 5xx HTTP status code +- **THEN** the status column renders in red (`#E07B6B`) + +#### Scenario: In-flight indicator +- **WHEN** a delivery row has `in_flight = true` +- **THEN** the status column renders `⇡ in flight` in magenta (`#C98EC9`) + +#### Scenario: Column drop below 80 cols +- **WHEN** terminal width drops below 80 columns +- **THEN** suffix, then size, then latency columns are dropped right-to-left + +--- + +### Requirement: Responsive layout +The TUI SHALL recompute layout on every `tea.WindowSizeMsg`. Below 24 rows the identity block SHALL collapse to two lines (status + forwarding). + +#### Scenario: Viewport height recalculation +- **WHEN** a `tea.WindowSizeMsg` is received +- **THEN** viewport height is set to `termHeight − (headerRows + 2 dividers + 1 footer + 2 blank lines)` + +#### Scenario: Identity collapse +- **WHEN** terminal height is below 24 rows +- **THEN** the identity block renders only the session state pill and the forwarding route (2 lines instead of 4) + +--- + +### Requirement: Keybind bar +A persistent single-row footer SHALL always be visible and SHALL render inverted key chips followed by action labels: `c` copy URL, `w` web UI, `r` replay last, `p` pause/resume, `?` help, `q` quit. + +#### Scenario: Footer always rendered +- **WHEN** the TUI is active regardless of scroll position +- **THEN** the keybind bar is pinned to the last row of the terminal + +--- + +### Requirement: Copy forwarding URL +Pressing `c` SHALL write the public forwarding URL to the system clipboard and show a 1.5 s toast. + +#### Scenario: Clipboard success +- **WHEN** the user presses `c` and clipboard write succeeds +- **THEN** a toast "URL copied" appears below the keybind bar for 1.5 seconds then disappears + +#### Scenario: Clipboard failure +- **WHEN** the user presses `c` and clipboard write fails +- **THEN** a toast "copy failed — no clipboard" appears for 1.5 seconds + +--- + +### Requirement: Open web UI +Pressing `w` SHALL open the hooks server web dashboard URL in the system default browser. + +#### Scenario: Open browser +- **WHEN** the user presses `w` +- **THEN** the OS default browser opens to the hooks server URL + +--- + +### Requirement: Replay last delivery +Pressing `r` SHALL re-send the most recent completed delivery to the local target via the existing replay API. + +#### Scenario: Replay triggered +- **WHEN** the user presses `r` and at least one delivery exists +- **THEN** the replay API is called for the most recent delivery ID + +#### Scenario: No deliveries +- **WHEN** the user presses `r` and the delivery buffer is empty +- **THEN** the key press is a no-op + +--- + +### Requirement: Help overlay +Pressing `?` SHALL show a modal overlay listing all keybindings plus version and build info. Pressing `?` again or `Esc` SHALL dismiss it. + +#### Scenario: Help shown +- **WHEN** the user presses `?` +- **THEN** a modal overlay appears listing all keybindings + +#### Scenario: Help dismissed +- **WHEN** the help overlay is visible and the user presses `?` or `Esc` +- **THEN** the overlay is hidden and the delivery tail is visible again + +--- + +### Requirement: Graceful quit +Pressing `q` or `^C` SHALL initiate graceful shutdown: in-flight deliveries are drained before exit. A second press SHALL force-quit immediately. + +#### Scenario: First quit key — graceful drain +- **WHEN** the user presses `q` or `^C` with in-flight deliveries +- **THEN** the TUI shows a "draining…" indicator and waits for in-flight rows to complete before exiting + +#### Scenario: Second quit key — force quit +- **WHEN** the user presses `q` or `^C` a second time while draining +- **THEN** the program exits immediately without waiting for in-flight deliveries + +#### Scenario: Quit with no in-flight +- **WHEN** the user presses `q` or `^C` with no in-flight deliveries +- **THEN** the program exits immediately + +--- + +### Requirement: 1-second uptime tick +The TUI SHALL fire a `tickMsg` every second to refresh the uptime display in the session header. + +#### Scenario: Uptime increments +- **WHEN** one second elapses +- **THEN** the uptime counter in the session header increments by one second diff --git a/openspec/changes/hooksctl-tui/tasks.md b/openspec/changes/hooksctl-tui/tasks.md new file mode 100644 index 0000000..8470f5d --- /dev/null +++ b/openspec/changes/hooksctl-tui/tasks.md @@ -0,0 +1,85 @@ +## 1. Dependencies & Module Setup + +- [ ] 1.1 Add `github.com/charmbracelet/bubbletea`, `github.com/charmbracelet/bubbles`, `github.com/charmbracelet/lipgloss`, and `github.com/atotto/clipboard` to `go.mod` via `go get` +- [ ] 1.2 Run `make tidy` and commit updated `go.mod` / `go.sum` +- [ ] 1.3 Create `internal/tui/` package directory with a stub `doc.go` + +## 2. Message Types & Domain Types + +- [ ] 2.1 Define `SessionState` type (online / reconnecting / paused / offline) and `SessionInfo` struct (state, reconnect count, uptime start, account email, forwarding route, token prefix/suffix, scopes) +- [ ] 2.2 Define `Delivery` struct (id, recv_at, method, path, source, status, duration_ms, size_bytes, suffix, in_flight bool) +- [ ] 2.3 Define Bubble Tea message types: `DeliveryReceivedMsg`, `DeliveryCompletedMsg`, `SessionStateMsg`, `tickMsg`, `clipboardCopiedMsg` + +## 3. Lip Gloss Style Definitions + +- [ ] 3.1 Define color token constants matching the spec (`termGreen`, `termAmber`, `termRed`, `termBlue`, `termMagenta`, `termCyan`, `termFg`, `termDim`) using `lipgloss.Color` with `AdaptiveColor` light/dark pairs +- [ ] 3.2 Define named styles: `styleTitle`, `styleDim`, `styleStatusOnline`, `styleStatusReconnecting`, `styleStatusPaused`, `styleForwardURL`, `styleTargetURL`, `styleTokenHighlight`, `styleDivider`, `styleKeybind`, `styleToast` +- [ ] 3.3 Define status-code color function `statusStyle(code int) lipgloss.Style` + +## 4. Core Model + +- [ ] 4.1 Define `Model` struct with fields: `session SessionInfo`, `deliveries []Delivery` (ring buffer), `viewport viewport.Model`, `help help.Model`, `showHelp bool`, `atBottom bool`, `toastMsg string`, `toastExpiry time.Time`, `termW int`, `termH int`, `draining bool`, `keys keyMap` +- [ ] 4.2 Implement `New(session SessionInfo) Model` constructor that initialises the viewport and help model +- [ ] 4.3 Implement `Init() tea.Cmd` — returns `tea.Batch(tickCmd(), tea.RequestBackgroundColor)` +- [ ] 4.4 Implement ring-buffer append helper `appendDelivery(m *Model, d Delivery)` that evicts oldest when len >= 500 + +## 5. Key Bindings + +- [ ] 5.1 Define `keyMap` struct with `key.Binding` fields: `copyURL`, `openWeb`, `replayLast`, `pause`, `help`, `quit` +- [ ] 5.2 Implement `ShortHelp()` and `FullHelp()` on `keyMap` for the bubbles `help.Model` +- [ ] 5.3 Wire key bindings in `Update()` — `c`, `w`, `r`, `p`, `?`, `q`, `ctrl+c` + +## 6. Update Logic + +- [ ] 6.1 Handle `tea.WindowSizeMsg` — recompute `termW`, `termH`, viewport height via `viewportHeight()`, re-render content +- [ ] 6.2 Handle `tea.BackgroundColorMsg` — rebuild Lip Gloss styles for light/dark +- [ ] 6.3 Handle `DeliveryReceivedMsg` — append to ring buffer, scroll to bottom if `atBottom`, rebuild viewport content +- [ ] 6.4 Handle `DeliveryCompletedMsg` — find matching in-flight row by ID, update status/latency/suffix, rebuild viewport content +- [ ] 6.5 Handle `SessionStateMsg` — update `m.session`, re-render header +- [ ] 6.6 Handle `tickMsg` — refresh uptime display, expire toast if past `toastExpiry`, return next tick command +- [ ] 6.7 Handle `clipboardCopiedMsg` — set `toastMsg` and `toastExpiry = time.Now().Add(1.5s)` +- [ ] 6.8 Implement graceful quit: first `q`/`^C` sets `m.draining = true` (if in-flight rows exist); second press or zero in-flight calls `tea.Quit` +- [ ] 6.9 Implement pause/resume: toggle `m.session.State` between paused and online, fire command to pause/resume the SSE forwarder + +## 7. View / Rendering + +- [ ] 7.1 Implement `renderTitle(m Model) string` — `hooksctl ` left cyan-bold, right-aligned dim help hint +- [ ] 7.2 Implement `renderIdentity(m Model) string` — 4-row key/value block (session pill, account, forwarding route, token); collapse to 2 rows when `termH < 24` +- [ ] 7.3 Implement `renderDivider(w int) string` — `strings.Repeat("─", w)` in dim style +- [ ] 7.4 Implement `renderDeliveriesHeader() string` — "DELIVERIES" small-caps left, "newest ↓" dim right +- [ ] 7.5 Implement `renderDeliveryRow(d Delivery, termW int) string` — fixed-width columns with column-drop logic below 80 cols +- [ ] 7.6 Implement `renderKeybindBar(m Model) string` — inverted key chips + labels; append toast line when active +- [ ] 7.7 Implement `renderHelpOverlay(m Model) string` — modal box listing all bindings + version info +- [ ] 7.8 Implement `View() string` — compose title + identity + divider + deliveries header + viewport + divider + keybind bar; overlay help modal when `showHelp` +- [ ] 7.9 Implement `viewportHeight(termH, headerRows int) int` and verify off-by-one with a unit test + +## 8. Commands + +- [ ] 8.1 Implement `tickCmd() tea.Cmd` — fires `tickMsg{time.Now()}` after 1 s using `tea.Tick` +- [ ] 8.2 Implement `copyURLCmd(url string) tea.Cmd` — calls `clipboard.WriteAll(url)`, returns `clipboardCopiedMsg` or error toast msg +- [ ] 8.3 Implement `openBrowserCmd(url string) tea.Cmd` — calls `exec.Command("open"/"xdg-open"/"start", url).Start()` +- [ ] 8.4 Implement `replayCmd(deliveryID string, apiClient ...) tea.Cmd` — calls replay API endpoint + +## 9. TTY Detection & `forward` Command Wiring + +- [ ] 9.1 Add `golang.org/x/term` import to `cmd/hooksctl/forward.go` (already likely available transitively; confirm in `go.mod`) +- [ ] 9.2 In `forward.go` run loop: detect `term.IsTerminal(int(os.Stdout.Fd()))` before starting SSE consumer +- [ ] 9.3 If TTY: create `tui.Model`, create `tea.NewProgram(model, tea.WithAltScreen())`, run SSE consumer in a goroutine that calls `p.Send(tui.DeliveryReceivedMsg{...})` and `p.Send(tui.SessionStateMsg{...})` on events +- [ ] 9.4 If not TTY: keep existing structured-log path unchanged +- [ ] 9.5 Wire `defer p.RestoreTerminal()` for panic safety + +## 10. Tests + +- [ ] 10.1 Unit test `viewportHeight()` for standard, short-terminal, and edge-case inputs +- [ ] 10.2 Unit test `renderDeliveryRow()` for 2xx/4xx/5xx color paths and column-drop at < 80 cols +- [ ] 10.3 Unit test `appendDelivery()` ring-buffer eviction at cap 500 +- [ ] 10.4 Unit test `Update()` for `DeliveryReceivedMsg` (appends, scroll behavior) and `DeliveryCompletedMsg` (patches in-flight row) +- [ ] 10.5 Unit test graceful-quit state machine (first press → draining, second press → quit) +- [ ] 10.6 Run `make lint && make test` to confirm no regressions + +## 11. Smoke Test & Cleanup + +- [ ] 11.1 Run `make dev` and `hooksctl forward render` in a real terminal; verify all regions render correctly +- [ ] 11.2 Test resize by dragging terminal window; confirm column drop and identity collapse +- [ ] 11.3 Test `c` (copy URL), `w` (open browser), `p` (pause/resume), `?` (help overlay), `q` (graceful quit) +- [ ] 11.4 Remove stub `doc.go` if package has real files; ensure no dead code From 4dc8e6befbeab733175c8e1731f7d541a3f94627 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 11 May 2026 22:47:29 -0700 Subject: [PATCH 2/7] spec(tui): simplify hooksctl-tui change artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Commit to Bubble Tea v2; drop stale "pin v1" caveat - Single-phase quit (drop two-phase draining state machine) - Visual-only pause/resume (no back-channel to SSE goroutine) - Remove open-browser keybind (w) — no concrete use case - Defer replay keybind (r) to v2 — requires undefined API client - Specify column-drop thresholds: suffix <80, size <73, latency <65 - Toast replaces keybind bar for 1.5s rather than appearing below it --- openspec/changes/hooksctl-tui/design.md | 35 +++++++++--- openspec/changes/hooksctl-tui/proposal.md | 6 +-- .../hooksctl-tui/specs/forward-tui/spec.md | 54 +++++-------------- openspec/changes/hooksctl-tui/tasks.md | 20 ++++--- 4 files changed, 54 insertions(+), 61 deletions(-) diff --git a/openspec/changes/hooksctl-tui/design.md b/openspec/changes/hooksctl-tui/design.md index fc14728..1419b8e 100644 --- a/openspec/changes/hooksctl-tui/design.md +++ b/openspec/changes/hooksctl-tui/design.md @@ -46,19 +46,41 @@ Alternatives considered: running SSE inside a `tea.Cmd` — awkward because SSE Alternatives considered: rendering directly from a slice without viewport (manual scroll math, more error-prone). -### 4. Lip Gloss styles defined at package init, not per-render +### 4. Bubble Tea v2 + Lip Gloss styles defined at package init, not per-render -`var styles = newStyles()` is called once at startup using `lipgloss.HasDarkBackground`. On `tea.BackgroundColorMsg` the styles are rebuilt. This avoids re-allocating `lipgloss.Style` objects on every frame. - -Alternatives considered: passing renderer to each function (Lip Gloss v2 encourages this but Lip Gloss v1 is simpler and already in the ecosystem; pin v1 for now). +Using Bubble Tea v2. `var styles = newStyles()` is called once at startup using `lipgloss.HasDarkBackground`. On `tea.BackgroundColorMsg` (a v2 feature) the styles are rebuilt. This avoids re-allocating `lipgloss.Style` objects on every frame. ### 5. `github.com/atotto/clipboard` for copy-URL Cross-platform clipboard access. Wrapped in a `tea.Cmd` so it doesn't block the update loop. On success fires `clipboardCopiedMsg` which shows a 1.5 s toast. -### 6. TTY detection gates TUI entry +### 6. Single-phase quit + +`q`/`^C` cancels the SSE consumer context and calls `tea.Quit` immediately. No two-phase draining state machine. Forwarding to localhost is sub-100ms; owning delivery completion in the TUI adds state machine complexity for negligible UX benefit. + +### 7. Visual-only pause + +`p` toggles `m.session.State` between paused and online in the model only. No back-channel to the SSE consumer goroutine. Events continue to arrive; the header pill shows `● paused` in amber. This keeps `internal/tui` a pure presentation layer with a single inbound event channel. + +### 8. No open-browser keybind + +`w` (open web UI) has no concrete use case and adds platform-specific `exec.Command` dispatch. Removed from keybinds and dependencies. + +### 9. Replay deferred to v2 + +`r` (replay last delivery) requires an API client injected into the TUI package, which conflicts with the package isolation constraint. Deferred. + +### 10. Column drop thresholds + +Below 80 cols: drop suffix. Below 73 cols: also drop size. Below 65 cols: also drop latency. Fixed columns sum to ~47 chars + path minimum of 12, giving headroom for each step. + +### 11. Toast replaces keybind bar + +The 1.5 s clipboard toast overwrites the keybind bar text for its duration, then the bar returns. Footer stays one row; no layout reflow on toast appear/dismiss. + +### 12. TTY detection gates TUI entry -`cmd/hooksctl/forward.go` checks `golang.org/x/term`.`IsTerminal(int(os.Stdout.Fd()))`. If false, falls back to existing structured-log output unchanged. This means CI/pipe usage is unaffected. +`cmd/hooksctl/forward.go` checks `golang.org/x/term.IsTerminal(int(os.Stdout.Fd()))`. If false, falls back to existing structured-log output unchanged. This means CI/pipe usage is unaffected. ## Risks / Trade-offs @@ -81,4 +103,3 @@ Rollback: revert the TTY-detection branch in `forward.go`; the rest of the code ## Open Questions - **Should `hooksctl tail` also get TUI treatment?** Tail is read-only and simpler — leave for v2. -- **Lip Gloss v1 vs v2?** v2 is in beta; stick with stable v1 (`v0.x`) for now and migrate after GA. diff --git a/openspec/changes/hooksctl-tui/proposal.md b/openspec/changes/hooksctl-tui/proposal.md index c4b79ff..7e4c122 100644 --- a/openspec/changes/hooksctl-tui/proposal.md +++ b/openspec/changes/hooksctl-tui/proposal.md @@ -8,8 +8,8 @@ - **`hooksctl forward` gains a TUI mode** — the command boots into the full-screen dashboard instead of logging to stdout when stdout is a TTY. - **Ring-buffered delivery log** — up to 500 deliveries displayed with timestamp, method, path, source, status, latency, size, and optional suffix (retry N/M, error label). - **Live session header** — shows session state (online/reconnecting/paused/offline), reconnect count, uptime, account email, forwarding route, and token fingerprint. -- **Keybind bar** — persistent footer: copy forwarding URL, open web UI, replay last delivery, pause/resume, help overlay, quit. -- **Graceful quit** — first `q`/`^C` drains in-flight deliveries; second press force-quits. +- **Keybind bar** — persistent footer: copy forwarding URL, pause/resume, help overlay, quit. +- **Quit** — `q`/`^C` cancels the SSE consumer and exits immediately. - **Responsive layout** — columns drop right-to-left (suffix → size → latency) below 80 cols; identity block collapses to two lines below 24 rows. ## Capabilities @@ -24,7 +24,7 @@ _(none — the existing `forward` HTTP/SSE logic is unchanged; the TUI is wired ## Impact -- **New dependency**: `github.com/charmbracelet/bubbletea`, `github.com/charmbracelet/bubbles`, `github.com/charmbracelet/lipgloss`, `github.com/atotto/clipboard`. +- **New dependency**: `github.com/charmbracelet/bubbletea` (v2), `github.com/charmbracelet/bubbles`, `github.com/charmbracelet/lipgloss`, `github.com/atotto/clipboard`. - **`cmd/hooksctl`** — `forward` command detects TTY and hands off to the TUI model. - **`internal/tui`** — new package; no changes to existing packages. - **No server-side changes** — the TUI consumes the existing SSE `/subscribe` stream. diff --git a/openspec/changes/hooksctl-tui/specs/forward-tui/spec.md b/openspec/changes/hooksctl-tui/specs/forward-tui/spec.md index 2c84c1e..b7cfe4e 100644 --- a/openspec/changes/hooksctl-tui/specs/forward-tui/spec.md +++ b/openspec/changes/hooksctl-tui/specs/forward-tui/spec.md @@ -26,7 +26,7 @@ The TUI header SHALL display four rows of session metadata: (1) session state pi #### Scenario: Paused state - **WHEN** the user presses `p` -- **THEN** the session pill reads `● paused` in amber and inbound forwarding is queued +- **THEN** the session pill reads `● paused` in amber; events continue to arrive but the visual state reflects paused #### Scenario: Token fingerprint display - **WHEN** the TUI renders the token row @@ -76,7 +76,11 @@ Each delivery row SHALL render fixed-width columns in this order: timestamp (12) #### Scenario: Column drop below 80 cols - **WHEN** terminal width drops below 80 columns -- **THEN** suffix, then size, then latency columns are dropped right-to-left +- **THEN** suffix column is dropped +- **WHEN** terminal width drops below 73 columns +- **THEN** size column is also dropped +- **WHEN** terminal width drops below 65 columns +- **THEN** latency column is also dropped --- @@ -94,7 +98,7 @@ The TUI SHALL recompute layout on every `tea.WindowSizeMsg`. Below 24 rows the i --- ### Requirement: Keybind bar -A persistent single-row footer SHALL always be visible and SHALL render inverted key chips followed by action labels: `c` copy URL, `w` web UI, `r` replay last, `p` pause/resume, `?` help, `q` quit. +A persistent single-row footer SHALL always be visible and SHALL render inverted key chips followed by action labels: `c` copy URL, `p` pause/resume, `?` help, `q` quit. #### Scenario: Footer always rendered - **WHEN** the TUI is active regardless of scroll position @@ -107,33 +111,11 @@ Pressing `c` SHALL write the public forwarding URL to the system clipboard and s #### Scenario: Clipboard success - **WHEN** the user presses `c` and clipboard write succeeds -- **THEN** a toast "URL copied" appears below the keybind bar for 1.5 seconds then disappears +- **THEN** the keybind bar text is replaced by "URL copied" for 1.5 seconds, then the bar is restored #### Scenario: Clipboard failure - **WHEN** the user presses `c` and clipboard write fails -- **THEN** a toast "copy failed — no clipboard" appears for 1.5 seconds - ---- - -### Requirement: Open web UI -Pressing `w` SHALL open the hooks server web dashboard URL in the system default browser. - -#### Scenario: Open browser -- **WHEN** the user presses `w` -- **THEN** the OS default browser opens to the hooks server URL - ---- - -### Requirement: Replay last delivery -Pressing `r` SHALL re-send the most recent completed delivery to the local target via the existing replay API. - -#### Scenario: Replay triggered -- **WHEN** the user presses `r` and at least one delivery exists -- **THEN** the replay API is called for the most recent delivery ID - -#### Scenario: No deliveries -- **WHEN** the user presses `r` and the delivery buffer is empty -- **THEN** the key press is a no-op +- **THEN** the keybind bar text is replaced by "copy failed — no clipboard" for 1.5 seconds, then the bar is restored --- @@ -150,20 +132,12 @@ Pressing `?` SHALL show a modal overlay listing all keybindings plus version and --- -### Requirement: Graceful quit -Pressing `q` or `^C` SHALL initiate graceful shutdown: in-flight deliveries are drained before exit. A second press SHALL force-quit immediately. - -#### Scenario: First quit key — graceful drain -- **WHEN** the user presses `q` or `^C` with in-flight deliveries -- **THEN** the TUI shows a "draining…" indicator and waits for in-flight rows to complete before exiting - -#### Scenario: Second quit key — force quit -- **WHEN** the user presses `q` or `^C` a second time while draining -- **THEN** the program exits immediately without waiting for in-flight deliveries +### Requirement: Quit +Pressing `q` or `^C` SHALL cancel the SSE consumer and exit immediately. -#### Scenario: Quit with no in-flight -- **WHEN** the user presses `q` or `^C` with no in-flight deliveries -- **THEN** the program exits immediately +#### Scenario: Quit +- **WHEN** the user presses `q` or `^C` +- **THEN** the SSE consumer context is cancelled and the program exits --- diff --git a/openspec/changes/hooksctl-tui/tasks.md b/openspec/changes/hooksctl-tui/tasks.md index 8470f5d..130ed69 100644 --- a/openspec/changes/hooksctl-tui/tasks.md +++ b/openspec/changes/hooksctl-tui/tasks.md @@ -18,16 +18,16 @@ ## 4. Core Model -- [ ] 4.1 Define `Model` struct with fields: `session SessionInfo`, `deliveries []Delivery` (ring buffer), `viewport viewport.Model`, `help help.Model`, `showHelp bool`, `atBottom bool`, `toastMsg string`, `toastExpiry time.Time`, `termW int`, `termH int`, `draining bool`, `keys keyMap` +- [ ] 4.1 Define `Model` struct with fields: `session SessionInfo`, `deliveries []Delivery` (ring buffer), `viewport viewport.Model`, `help help.Model`, `showHelp bool`, `atBottom bool`, `toastMsg string`, `toastExpiry time.Time`, `termW int`, `termH int`, `keys keyMap` - [ ] 4.2 Implement `New(session SessionInfo) Model` constructor that initialises the viewport and help model - [ ] 4.3 Implement `Init() tea.Cmd` — returns `tea.Batch(tickCmd(), tea.RequestBackgroundColor)` - [ ] 4.4 Implement ring-buffer append helper `appendDelivery(m *Model, d Delivery)` that evicts oldest when len >= 500 ## 5. Key Bindings -- [ ] 5.1 Define `keyMap` struct with `key.Binding` fields: `copyURL`, `openWeb`, `replayLast`, `pause`, `help`, `quit` +- [ ] 5.1 Define `keyMap` struct with `key.Binding` fields: `copyURL`, `pause`, `help`, `quit` - [ ] 5.2 Implement `ShortHelp()` and `FullHelp()` on `keyMap` for the bubbles `help.Model` -- [ ] 5.3 Wire key bindings in `Update()` — `c`, `w`, `r`, `p`, `?`, `q`, `ctrl+c` +- [ ] 5.3 Wire key bindings in `Update()` — `c`, `p`, `?`, `q`, `ctrl+c` ## 6. Update Logic @@ -38,8 +38,8 @@ - [ ] 6.5 Handle `SessionStateMsg` — update `m.session`, re-render header - [ ] 6.6 Handle `tickMsg` — refresh uptime display, expire toast if past `toastExpiry`, return next tick command - [ ] 6.7 Handle `clipboardCopiedMsg` — set `toastMsg` and `toastExpiry = time.Now().Add(1.5s)` -- [ ] 6.8 Implement graceful quit: first `q`/`^C` sets `m.draining = true` (if in-flight rows exist); second press or zero in-flight calls `tea.Quit` -- [ ] 6.9 Implement pause/resume: toggle `m.session.State` between paused and online, fire command to pause/resume the SSE forwarder +- [ ] 6.8 Implement quit: `q`/`^C` cancels the SSE consumer context and calls `tea.Quit` immediately +- [ ] 6.9 Implement visual-only pause/resume: toggle `m.session.State` between paused and online; no back-channel to the SSE goroutine ## 7. View / Rendering @@ -47,8 +47,8 @@ - [ ] 7.2 Implement `renderIdentity(m Model) string` — 4-row key/value block (session pill, account, forwarding route, token); collapse to 2 rows when `termH < 24` - [ ] 7.3 Implement `renderDivider(w int) string` — `strings.Repeat("─", w)` in dim style - [ ] 7.4 Implement `renderDeliveriesHeader() string` — "DELIVERIES" small-caps left, "newest ↓" dim right -- [ ] 7.5 Implement `renderDeliveryRow(d Delivery, termW int) string` — fixed-width columns with column-drop logic below 80 cols -- [ ] 7.6 Implement `renderKeybindBar(m Model) string` — inverted key chips + labels; append toast line when active +- [ ] 7.5 Implement `renderDeliveryRow(d Delivery, termW int) string` — fixed-width columns with column-drop thresholds: suffix dropped below 80 cols, size also dropped below 73, latency also dropped below 65 +- [ ] 7.6 Implement `renderKeybindBar(m Model) string` — inverted key chips + labels; when toast is active, render toast text in place of keybind labels for 1.5 s, then restore - [ ] 7.7 Implement `renderHelpOverlay(m Model) string` — modal box listing all bindings + version info - [ ] 7.8 Implement `View() string` — compose title + identity + divider + deliveries header + viewport + divider + keybind bar; overlay help modal when `showHelp` - [ ] 7.9 Implement `viewportHeight(termH, headerRows int) int` and verify off-by-one with a unit test @@ -57,8 +57,6 @@ - [ ] 8.1 Implement `tickCmd() tea.Cmd` — fires `tickMsg{time.Now()}` after 1 s using `tea.Tick` - [ ] 8.2 Implement `copyURLCmd(url string) tea.Cmd` — calls `clipboard.WriteAll(url)`, returns `clipboardCopiedMsg` or error toast msg -- [ ] 8.3 Implement `openBrowserCmd(url string) tea.Cmd` — calls `exec.Command("open"/"xdg-open"/"start", url).Start()` -- [ ] 8.4 Implement `replayCmd(deliveryID string, apiClient ...) tea.Cmd` — calls replay API endpoint ## 9. TTY Detection & `forward` Command Wiring @@ -74,12 +72,12 @@ - [ ] 10.2 Unit test `renderDeliveryRow()` for 2xx/4xx/5xx color paths and column-drop at < 80 cols - [ ] 10.3 Unit test `appendDelivery()` ring-buffer eviction at cap 500 - [ ] 10.4 Unit test `Update()` for `DeliveryReceivedMsg` (appends, scroll behavior) and `DeliveryCompletedMsg` (patches in-flight row) -- [ ] 10.5 Unit test graceful-quit state machine (first press → draining, second press → quit) +- [ ] 10.5 Unit test quit: `q`/`^C` produces `tea.Quit` immediately regardless of in-flight state - [ ] 10.6 Run `make lint && make test` to confirm no regressions ## 11. Smoke Test & Cleanup - [ ] 11.1 Run `make dev` and `hooksctl forward render` in a real terminal; verify all regions render correctly - [ ] 11.2 Test resize by dragging terminal window; confirm column drop and identity collapse -- [ ] 11.3 Test `c` (copy URL), `w` (open browser), `p` (pause/resume), `?` (help overlay), `q` (graceful quit) +- [ ] 11.3 Test `c` (copy URL + toast), `p` (pause/resume pill), `?` (help overlay), `q` (quit) - [ ] 11.4 Remove stub `doc.go` if package has real files; ensure no dead code From fe495e492f4567bdef5227ac3a34d5bef1775bc6 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 11 May 2026 23:20:38 -0700 Subject: [PATCH 3/7] feat(tui): add full-screen Bubble Tea TUI to hooksctl forward Implements the hooksctl-tui OpenSpec change. When stdout is a TTY, `hooksctl forward` now boots into an ngrok-style dashboard instead of logging to stdout. The non-TTY path (CI/pipe) is unchanged. - New internal/tui package: Bubble Tea v2 model with ring-buffered delivery log (cap 500), live session header (state pill, uptime, account, route, token fingerprint), responsive layout (column drop <80/<73/<65 cols, identity collapse <24 rows), keybind bar with clipboard copy, visual pause/resume, and help overlay. - forward.go: TTY detection via charmbracelet/x/term; TUI path runs SSE consumer in a goroutine and sends DeliveryReceivedMsg / DeliveryCompletedMsg / SessionStateMsg to the Bubble Tea program. - Dependencies: charm.land/bubbletea/v2, charm.land/bubbles/v2, github.com/atotto/clipboard. --- cmd/hooksctl/forward.go | 239 +++++++++++++++++++++++ go.mod | 5 +- go.sum | 10 +- internal/tui/commands.go | 23 +++ internal/tui/keys.go | 40 ++++ internal/tui/model.go | 151 +++++++++++++++ internal/tui/styles.go | 67 +++++++ internal/tui/tui_test.go | 238 +++++++++++++++++++++++ internal/tui/types.go | 58 ++++++ internal/tui/view.go | 255 +++++++++++++++++++++++++ openspec/changes/hooksctl-tui/tasks.md | 96 +++++----- 11 files changed, 1131 insertions(+), 51 deletions(-) create mode 100644 internal/tui/commands.go create mode 100644 internal/tui/keys.go create mode 100644 internal/tui/model.go create mode 100644 internal/tui/styles.go create mode 100644 internal/tui/tui_test.go create mode 100644 internal/tui/types.go create mode 100644 internal/tui/view.go diff --git a/cmd/hooksctl/forward.go b/cmd/hooksctl/forward.go index 4685b0f..32f3d0a 100644 --- a/cmd/hooksctl/forward.go +++ b/cmd/hooksctl/forward.go @@ -20,7 +20,10 @@ import ( "syscall" "time" + tea "charm.land/bubbletea/v2" + xterm "github.com/charmbracelet/x/term" "github.com/onebusaway/hooks/internal/push" + "github.com/onebusaway/hooks/internal/tui" ) // forwardTestCtx is non-nil only in tests; production paths derive their @@ -84,6 +87,10 @@ func cmdForward(g globals, args []string) int { cli := &http.Client{Timeout: *timeout} + if xterm.IsTerminal(os.Stdout.Fd()) { + return runWithTUI(ctx, cancel, g, source, *to, subscribeToken, cursorPath, &startCursor, cli, *exitOnError) + } + for { if err := streamFromCursor(ctx, g, subscribeToken, source, &startCursor, cursorPath, *to, cli, *exitOnError); err != nil { if ctx.Err() != nil { @@ -107,6 +114,238 @@ func cmdForward(g globals, args []string) int { } } +// runWithTUI runs the forward loop in a goroutine and drives a Bubble Tea TUI +// in the foreground. cancel is called by the TUI when the user quits. +func runWithTUI(ctx context.Context, cancel context.CancelFunc, g globals, source, to, subscribeToken, cursorPath string, cursor *int64, cli *http.Client, exitOnError bool) int { + prefix, suffix := tokenFingerprint(subscribeToken) + baseSession := tui.SessionInfo{ + State: tui.StateOnline, + UptimeStart: time.Now(), + ForwardURL: strings.TrimRight(g.Server, "/") + "/subscribe/" + source, + TargetURL: to, + TokenPrefix: prefix, + TokenSuffix: suffix, + Scopes: []string{source}, + } + + model := tui.New(baseSession, cancel) + prog := tea.NewProgram(model) + + go func() { + reconnectCount := 0 + for { + prog.Send(tui.SessionStateMsg{Info: tui.SessionInfo{ + State: tui.StateOnline, + ReconnectCount: reconnectCount, + UptimeStart: baseSession.UptimeStart, + ForwardURL: baseSession.ForwardURL, + TargetURL: baseSession.TargetURL, + TokenPrefix: prefix, + TokenSuffix: suffix, + Scopes: baseSession.Scopes, + }}) + + err := streamFromCursorTUI(ctx, prog, g, subscribeToken, source, cursor, cursorPath, to, cli, exitOnError) + if err == nil || ctx.Err() != nil { + return + } + + if exitOnError { + cancel() + return + } + + reconnectCount++ + prog.Send(tui.SessionStateMsg{Info: tui.SessionInfo{ + State: tui.StateReconnecting, + ReconnectCount: reconnectCount, + UptimeStart: baseSession.UptimeStart, + ForwardURL: baseSession.ForwardURL, + TargetURL: baseSession.TargetURL, + TokenPrefix: prefix, + TokenSuffix: suffix, + Scopes: baseSession.Scopes, + }}) + + d := backoff() + select { + case <-ctx.Done(): + return + case <-time.After(d): + } + } + }() + + defer prog.RestoreTerminal() //nolint:errcheck + if _, err := prog.Run(); err != nil { + fmt.Fprintln(os.Stderr, "forward:", err) + return 1 + } + return 0 +} + +// streamFromCursorTUI is like streamFromCursor but sends delivery events to prog. +func streamFromCursorTUI(ctx context.Context, prog *tea.Program, g globals, bearer, source string, cursor *int64, cursorPath, to string, cli *http.Client, exitOnError bool) error { + endpoint := fmt.Sprintf("%s/subscribe/%s?since=%d", strings.TrimRight(g.Server, "/"), source, *cursor) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+bearer) + req.Header.Set("Accept", "text/event-stream") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("subscribe returned %d", resp.StatusCode) + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 1<<20), 1<<20) + current := map[string]string{} + for scanner.Scan() { + line := scanner.Text() + switch { + case line == "": + if len(current) == 0 { + continue + } + seq, err := strconv.ParseInt(current["id"], 10, 64) + if err != nil { + current = map[string]string{} + continue + } + if err := forwardOneTUI(ctx, prog, cli, to, current, source, exitOnError); err != nil { + return err + } + *cursor = seq + saveCursor(cursorPath, seq) + current = map[string]string{} + case strings.HasPrefix(line, ":"): + // keepalive + default: + if i := strings.Index(line, ":"); i >= 0 { + current[line[:i]] = strings.TrimPrefix(line[i+1:], " ") + } + } + } + return scanner.Err() +} + +// forwardOneTUI forwards a single event and notifies prog of delivery start/completion. +func forwardOneTUI(ctx context.Context, prog *tea.Program, cli *http.Client, to string, msg map[string]string, source string, exitOnError bool) error { + var p struct { + DeliveryID string `json:"delivery_id"` + ProviderTimestamp time.Time `json:"provider_timestamp"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` + } + if err := json.Unmarshal([]byte(msg["data"]), &p); err != nil { + return fmt.Errorf("parse event: %w", err) + } + bodyBytes, err := base64.StdEncoding.DecodeString(p.Body) + if err != nil { + return fmt.Errorf("decode body: %w", err) + } + + delivID := p.DeliveryID + if delivID == "" { + delivID = msg["id"] + } + + prog.Send(tui.DeliveryReceivedMsg{Delivery: tui.Delivery{ + ID: delivID, + RecvAt: time.Now(), + Method: "POST", + Path: "/" + source, + Source: source, + InFlight: true, + SizeBytes: int64(len(bodyBytes)), + }}) + + start := time.Now() + var finalStatus int + var forwardErr error + + for attempt := 0; ; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, to, bytes.NewReader(bodyBytes)) + if err != nil { + forwardErr = err + break + } + for k, v := range p.Headers { + if push.IsHopByHop(k) { + continue + } + req.Header.Set(k, v) + } + req.Header.Set("X-Hooks-Delivery-Id", p.DeliveryID) + req.Header.Set("X-Hooks-Sequence", msg["id"]) + req.Header.Set("X-Hooks-Source", msg["event"]) + + resp, err := cli.Do(req) + if err != nil { + if ctx.Err() != nil { + forwardErr = ctx.Err() + break + } + if exitOnError { + forwardErr = fmt.Errorf("transport: %w", err) + break + } + if !sleepWithCtx(ctx, attemptBackoff(attempt)) { + forwardErr = ctx.Err() + break + } + continue + } + _ = resp.Body.Close() + finalStatus = resp.StatusCode + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + break + } + if exitOnError { + forwardErr = fmt.Errorf("target returned %d", resp.StatusCode) + break + } + if !sleepWithCtx(ctx, attemptBackoff(attempt)) { + forwardErr = ctx.Err() + break + } + } + + suffix := "" + if forwardErr != nil && ctx.Err() == nil { + suffix = "err" + if finalStatus == 0 { + finalStatus = 0 + } + } + + prog.Send(tui.DeliveryCompletedMsg{ + ID: delivID, + Status: finalStatus, + DurationMS: time.Since(start).Milliseconds(), + Suffix: suffix, + }) + + return forwardErr +} + +// tokenFingerprint returns the first 6 and last 3 characters of a token. +func tokenFingerprint(token string) (prefix, suffix string) { + r := []rune(token) + if len(r) > 9 { + return string(r[:6]), string(r[len(r)-3:]) + } + if len(r) > 3 { + return string(r[:3]), string(r[len(r)-3:]) + } + return token, "" +} + func streamFromCursor(ctx context.Context, g globals, bearer, source string, cursor *int64, cursorPath, to string, cli *http.Client, exitOnError bool) error { endpoint := fmt.Sprintf("%s/subscribe/%s?since=%d", strings.TrimRight(g.Server, "/"), source, *cursor) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) diff --git a/go.mod b/go.mod index 5dc4935..d1ee91a 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,8 @@ require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect cel.dev/expr v0.25.1 // indirect + charm.land/bubbles/v2 v2.1.0 // indirect + charm.land/bubbletea/v2 v2.0.6 // indirect charm.land/lipgloss/v2 v2.0.3 // indirect codeberg.org/chavacava/garif v0.2.0 // indirect codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect @@ -43,6 +45,7 @@ require ( github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect github.com/ashanbrown/makezero/v2 v2.2.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bkielbasa/cyclop v1.2.3 // indirect github.com/blizzy78/varnamelen v0.8.0 // indirect @@ -57,7 +60,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charithe/durationcheck v0.0.11 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect diff --git a/go.sum b/go.sum index 07ceeee..6b0ecba 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,10 @@ 4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= +charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= @@ -106,6 +110,8 @@ github.com/ashanbrown/forbidigo/v2 v2.3.1 h1:KAZijvQ7zeIBKbhikT4jCm0TLYXC4u78bTi github.com/ashanbrown/forbidigo/v2 v2.3.1/go.mod h1:2QDkLTzU6TV937eFROamXrW92M3paehdae4HCDCOZCM= github.com/ashanbrown/makezero/v2 v2.2.1 h1:A7uU8dgB1PA9aelTxHMfHIQ8Qev8AB3JLxJUBUsejqM= github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 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= @@ -140,8 +146,8 @@ github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1Di github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI= -github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= diff --git a/internal/tui/commands.go b/internal/tui/commands.go new file mode 100644 index 0000000..9b12de4 --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,23 @@ +package tui + +import ( + "time" + + tea "charm.land/bubbletea/v2" + "github.com/atotto/clipboard" +) + +func tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg{t: t} + }) +} + +func copyURLCmd(url string) tea.Cmd { + return func() tea.Msg { + if err := clipboard.WriteAll(url); err != nil { + return clipboardCopiedMsg{msg: "copy failed — no clipboard"} + } + return clipboardCopiedMsg{msg: "URL copied"} + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..c97c11f --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,40 @@ +package tui + +import "charm.land/bubbles/v2/key" + +type keyMap struct { + copyURL key.Binding + pause key.Binding + help key.Binding + quit key.Binding +} + +var defaultKeyMap = keyMap{ + copyURL: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "copy URL"), + ), + pause: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "pause/resume"), + ), + help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.copyURL, k.pause, k.help, k.quit} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.copyURL, k.pause}, + {k.help, k.quit}, + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..9e4692b --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,151 @@ +package tui + +import ( + "context" + "strings" + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" +) + +const ringCap = 500 + +// Model is the Bubble Tea model for the hooksctl forward TUI. +type Model struct { + session SessionInfo + deliveries []Delivery + vp viewport.Model + help help.Model + showHelp bool + atBottom bool + toastMsg string + toastExpiry time.Time + termW int + termH int + keys keyMap + st tuiStyles + cancel context.CancelFunc +} + +// New returns a Model ready to be run by a Bubble Tea program. +func New(session SessionInfo, cancel context.CancelFunc) Model { + m := Model{ + session: session, + atBottom: true, + keys: defaultKeyMap, + st: newStyles(true), + cancel: cancel, + } + m.vp = viewport.New() + m.help = help.New() + return m +} + +// Init satisfies tea.Model. +func (m Model) Init() tea.Cmd { + return tea.Batch(tickCmd(), tea.RequestBackgroundColor) +} + +// appendDelivery appends d to the ring buffer, evicting the oldest entry when at cap. +func appendDelivery(m *Model, d Delivery) { + if len(m.deliveries) >= ringCap { + m.deliveries = m.deliveries[1:] + } + m.deliveries = append(m.deliveries, d) +} + +// rebuildViewport re-renders all delivery rows into the viewport. +func rebuildViewport(m *Model) { + var sb strings.Builder + for i, d := range m.deliveries { + sb.WriteString(renderDeliveryRow(d, m.termW, m.st)) + if i < len(m.deliveries)-1 { + sb.WriteByte('\n') + } + } + m.vp.SetContent(sb.String()) + if m.atBottom { + m.vp.GotoBottom() + } +} + +// Update handles all Bubble Tea messages. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.termW = msg.Width + m.termH = msg.Height + headerRows := fixedHeaderRows(m.termH) + m.vp.SetWidth(m.termW) + m.vp.SetHeight(viewportHeight(m.termH, headerRows)) + m.help.SetWidth(m.termW) + rebuildViewport(&m) + + case tea.BackgroundColorMsg: + m.st = newStyles(msg.IsDark()) + rebuildViewport(&m) + + case DeliveryReceivedMsg: + appendDelivery(&m, msg.Delivery) + rebuildViewport(&m) + + case DeliveryCompletedMsg: + for i := range m.deliveries { + if m.deliveries[i].ID == msg.ID { + m.deliveries[i].Status = msg.Status + m.deliveries[i].DurationMS = msg.DurationMS + m.deliveries[i].Suffix = msg.Suffix + m.deliveries[i].InFlight = false + break + } + } + rebuildViewport(&m) + + case SessionStateMsg: + m.session = msg.Info + + case tickMsg: + cmds = append(cmds, tickCmd()) + if !m.toastExpiry.IsZero() && time.Now().After(m.toastExpiry) { + m.toastMsg = "" + m.toastExpiry = time.Time{} + } + + case clipboardCopiedMsg: + m.toastMsg = msg.msg + m.toastExpiry = time.Now().Add(1500 * time.Millisecond) + + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keys.quit): + if m.cancel != nil { + m.cancel() + } + return m, tea.Quit + case key.Matches(msg, m.keys.copyURL): + cmds = append(cmds, copyURLCmd(m.session.ForwardURL)) + case key.Matches(msg, m.keys.pause): + if m.session.State == StatePaused { + m.session.State = StateOnline + } else { + m.session.State = StatePaused + } + case key.Matches(msg, m.keys.help): + m.showHelp = !m.showHelp + default: + var vpCmd tea.Cmd + m.vp, vpCmd = m.vp.Update(msg) + if vpCmd != nil { + cmds = append(cmds, vpCmd) + } + m.atBottom = m.vp.AtBottom() + } + } + + return m, tea.Batch(cmds...) +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..288e784 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,67 @@ +package tui + +import lipgloss "charm.land/lipgloss/v2" + +type tuiStyles struct { + title lipgloss.Style + dim lipgloss.Style + label lipgloss.Style + statusOnline lipgloss.Style + statusReconnecting lipgloss.Style + statusPaused lipgloss.Style + statusOffline lipgloss.Style + forwardURL lipgloss.Style + targetURL lipgloss.Style + tokenHighlight lipgloss.Style + divider lipgloss.Style + keybindChip lipgloss.Style + toast lipgloss.Style + statusGreen lipgloss.Style + statusAmber lipgloss.Style + statusRed lipgloss.Style + statusMagenta lipgloss.Style +} + +func newStyles(isDark bool) tuiStyles { + ld := lipgloss.LightDark(isDark) + green := ld(lipgloss.Color("#5A7D1A"), lipgloss.Color("#9FC26A")) + amber := ld(lipgloss.Color("#B07300"), lipgloss.Color("#E3B341")) + red := ld(lipgloss.Color("#C03030"), lipgloss.Color("#E07B6B")) + blue := ld(lipgloss.Color("#1060A0"), lipgloss.Color("#6BB5E0")) + magenta := ld(lipgloss.Color("#8040A0"), lipgloss.Color("#C98EC9")) + dim := ld(lipgloss.Color("#909090"), lipgloss.Color("#626262")) + + return tuiStyles{ + title: lipgloss.NewStyle().Foreground(blue).Bold(true), + dim: lipgloss.NewStyle().Foreground(dim), + label: lipgloss.NewStyle().Foreground(dim), + statusOnline: lipgloss.NewStyle().Foreground(green).Bold(true), + statusReconnecting: lipgloss.NewStyle().Foreground(amber).Bold(true), + statusPaused: lipgloss.NewStyle().Foreground(amber).Bold(true), + statusOffline: lipgloss.NewStyle().Foreground(dim).Bold(true), + forwardURL: lipgloss.NewStyle().Foreground(blue), + targetURL: lipgloss.NewStyle().Foreground(dim), + tokenHighlight: lipgloss.NewStyle().Foreground(magenta), + divider: lipgloss.NewStyle().Foreground(dim), + keybindChip: lipgloss.NewStyle().Reverse(true), + toast: lipgloss.NewStyle().Foreground(amber).Bold(true), + statusGreen: lipgloss.NewStyle().Foreground(green), + statusAmber: lipgloss.NewStyle().Foreground(amber), + statusRed: lipgloss.NewStyle().Foreground(red), + statusMagenta: lipgloss.NewStyle().Foreground(magenta), + } +} + +// statusStyle returns the style for an HTTP status code. +func (s tuiStyles) statusStyle(code int) lipgloss.Style { + switch { + case code >= 500: + return s.statusRed + case code >= 400: + return s.statusAmber + case code >= 200: + return s.statusGreen + default: + return s.dim + } +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..5301765 --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,238 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" +) + +// --- viewportHeight --- + +func TestViewportHeight(t *testing.T) { + tests := []struct { + termH int + headerRows int + want int + }{ + {termH: 40, headerRows: 9, want: 31}, + {termH: 24, headerRows: 9, want: 15}, + {termH: 9, headerRows: 9, want: 0}, + {termH: 5, headerRows: 9, want: 0}, // edge: height < headerRows + {termH: 0, headerRows: 9, want: 0}, + } + for _, tc := range tests { + got := viewportHeight(tc.termH, tc.headerRows) + if got != tc.want { + t.Errorf("viewportHeight(%d, %d) = %d; want %d", tc.termH, tc.headerRows, got, tc.want) + } + } +} + +// --- renderDeliveryRow --- + +func TestRenderDeliveryRow_StatusColors(t *testing.T) { + st := newStyles(true) // dark mode + + d := func(status int) Delivery { + return Delivery{ + ID: "x", + RecvAt: time.Time{}, + Method: "POST", + Path: "/render", + Source: "render", + Status: status, + } + } + + // 2xx: green style + row2xx := renderDeliveryRow(d(200), 120, st) + green := st.statusGreen.Render("200 ") + if !strings.Contains(row2xx, green) { + t.Errorf("2xx row should contain green-styled status; got: %q", row2xx) + } + + // 4xx: amber style + row4xx := renderDeliveryRow(d(404), 120, st) + amber := st.statusAmber.Render("404 ") + if !strings.Contains(row4xx, amber) { + t.Errorf("4xx row should contain amber-styled status; got: %q", row4xx) + } + + // 5xx: red style + row5xx := renderDeliveryRow(d(500), 120, st) + red := st.statusRed.Render("500 ") + if !strings.Contains(row5xx, red) { + t.Errorf("5xx row should contain red-styled status; got: %q", row5xx) + } +} + +func TestRenderDeliveryRow_ColumnDrop(t *testing.T) { + st := newStyles(true) + d := Delivery{ + ID: "x", + RecvAt: time.Time{}, + Method: "POST", + Path: "/render", + Source: "render", + Status: 200, + DurationMS: 42, + SizeBytes: 1024, + Suffix: "retry 1/3", + } + + // At ≥80: suffix present + row80 := renderDeliveryRow(d, 80, st) + if !strings.Contains(row80, "retry 1/3") { + t.Errorf("at width 80, suffix should be present; row: %q", row80) + } + + // At <80: suffix dropped + row79 := renderDeliveryRow(d, 79, st) + if strings.Contains(row79, "retry 1/3") { + t.Errorf("at width 79, suffix should be dropped; row: %q", row79) + } + + // At <73: size dropped (no "1024B" or similar) + row72 := renderDeliveryRow(d, 72, st) + if strings.Contains(row72, "1024B") { + t.Errorf("at width 72, size should be dropped; row: %q", row72) + } + + // At <65: latency dropped (no "42ms") + row64 := renderDeliveryRow(d, 64, st) + if strings.Contains(row64, "42ms") { + t.Errorf("at width 64, latency should be dropped; row: %q", row64) + } +} + +// --- appendDelivery --- + +func TestAppendDelivery_RingBufferEviction(t *testing.T) { + m := Model{atBottom: true} + + // Fill to capacity + for i := range ringCap { + appendDelivery(&m, Delivery{ID: string(rune('a' + i%26)), RecvAt: time.Now()}) + } + if len(m.deliveries) != ringCap { + t.Fatalf("expected %d deliveries, got %d", ringCap, len(m.deliveries)) + } + + // One more should evict the oldest + appendDelivery(&m, Delivery{ID: "new", RecvAt: time.Now()}) + if len(m.deliveries) != ringCap { + t.Fatalf("after eviction expected %d deliveries, got %d", ringCap, len(m.deliveries)) + } + last := m.deliveries[ringCap-1] + if last.ID != "new" { + t.Errorf("expected last delivery to be 'new', got %q", last.ID) + } +} + +// --- Update: DeliveryReceivedMsg --- + +func newTestModel() Model { + m := New(SessionInfo{State: StateOnline, UptimeStart: time.Now()}, nil) + m.termW = 120 + m.termH = 40 + return m +} + +func TestUpdate_DeliveryReceived(t *testing.T) { + m := newTestModel() + + d := Delivery{ID: "d1", RecvAt: time.Now(), Method: "POST", Path: "/r", Source: "render", InFlight: true} + next, _ := m.Update(DeliveryReceivedMsg{Delivery: d}) + nm := next.(Model) + + if len(nm.deliveries) != 1 { + t.Fatalf("expected 1 delivery, got %d", len(nm.deliveries)) + } + if nm.deliveries[0].ID != "d1" { + t.Errorf("expected delivery id d1, got %q", nm.deliveries[0].ID) + } +} + +func TestUpdate_DeliveryCompleted(t *testing.T) { + m := newTestModel() + + d := Delivery{ID: "d1", RecvAt: time.Now(), Method: "POST", Path: "/r", Source: "render", InFlight: true} + next, _ := m.Update(DeliveryReceivedMsg{Delivery: d}) + nm := next.(Model) + + next2, _ := nm.Update(DeliveryCompletedMsg{ID: "d1", Status: 200, DurationMS: 50}) + nm2 := next2.(Model) + + if nm2.deliveries[0].InFlight { + t.Error("delivery should no longer be in-flight after completion") + } + if nm2.deliveries[0].Status != 200 { + t.Errorf("expected status 200, got %d", nm2.deliveries[0].Status) + } + if nm2.deliveries[0].DurationMS != 50 { + t.Errorf("expected DurationMS 50, got %d", nm2.deliveries[0].DurationMS) + } +} + +// --- Update: atBottom behavior --- + +func TestUpdate_ScrollUpDisablesAutoScroll(t *testing.T) { + m := newTestModel() + // Seed some deliveries + for i := range 10 { + d := Delivery{ID: string(rune('a' + i)), RecvAt: time.Now(), Method: "POST", Path: "/r", Source: "render"} + next, _ := m.Update(DeliveryReceivedMsg{Delivery: d}) + m = next.(Model) + } + if !m.atBottom { + t.Fatal("model should start at bottom") + } + + // Send a scroll-up key; viewport will handle it but atBottom should update + next, _ := m.Update(tea.KeyPressMsg{Code: 'k'}) + m = next.(Model) + // atBottom is updated to m.vp.AtBottom() after each unhandled key + // In a zero-size viewport AtBottom() may still be true; just confirm no panic + _ = m.atBottom +} + +// --- Update: quit --- + +func TestUpdate_QuitProducesQuitCmd(t *testing.T) { + cancelled := false + cancel := func() { cancelled = true } + + m := New(SessionInfo{State: StateOnline, UptimeStart: time.Now()}, cancel) + m.termW = 120 + m.termH = 40 + + _, cmd := m.Update(tea.KeyPressMsg{Code: 'q'}) + if cmd == nil { + t.Fatal("expected a command from quit key") + } + + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected QuitMsg, got %T", msg) + } + if !cancelled { + t.Error("cancel should have been called on quit") + } +} + +func TestUpdate_CtrlCProducesQuitCmd(t *testing.T) { + m := New(SessionInfo{State: StateOnline, UptimeStart: time.Now()}, nil) + m.termW = 120 + m.termH = 40 + + _, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if cmd == nil { + t.Fatal("expected a command from ctrl+c") + } + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected QuitMsg from ctrl+c, got %T", msg) + } +} diff --git a/internal/tui/types.go b/internal/tui/types.go new file mode 100644 index 0000000..e1645ea --- /dev/null +++ b/internal/tui/types.go @@ -0,0 +1,58 @@ +package tui + +import "time" + +// SessionState describes the connection state of a forward session. +type SessionState int + +const ( + StateOnline SessionState = iota + StateReconnecting SessionState = iota + StatePaused SessionState = iota + StateOffline SessionState = iota +) + +// SessionInfo holds the display data shown in the session header. +type SessionInfo struct { + State SessionState + ReconnectCount int + UptimeStart time.Time + Email string + ForwardURL string + TargetURL string + TokenPrefix string + TokenSuffix string + Scopes []string +} + +// Delivery represents a single webhook delivery row. +type Delivery struct { + ID string + RecvAt time.Time + Method string + Path string + Source string + Status int + DurationMS int64 + SizeBytes int64 + Suffix string + InFlight bool +} + +// DeliveryReceivedMsg is sent when a delivery is first received. +type DeliveryReceivedMsg struct{ Delivery Delivery } + +// DeliveryCompletedMsg is sent when an in-flight delivery completes. +type DeliveryCompletedMsg struct { + ID string + Status int + DurationMS int64 + Suffix string +} + +// SessionStateMsg is sent when the session connection state changes. +type SessionStateMsg struct{ Info SessionInfo } + +type tickMsg struct{ t time.Time } + +type clipboardCopiedMsg struct{ msg string } diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..3eae5b3 --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,255 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" +) + +// Version is the hooksctl version string shown in the TUI title and help overlay. +// Override at build time: -ldflags "-X charm.land/bubbletea/v2.Version=v1.2.3" +var Version = "dev" + +// View satisfies tea.Model and returns the full-screen TUI view. +func (m Model) View() tea.View { + v := tea.NewView(m.renderContent()) + v.AltScreen = true + return v +} + +func (m Model) renderContent() string { + if m.termW == 0 { + return "" + } + if m.showHelp { + return m.renderHelpScreen() + } + return m.renderMainScreen() +} + +func (m Model) renderMainScreen() string { + var sb strings.Builder + sb.WriteString(renderTitle(m)) + sb.WriteByte('\n') + sb.WriteString(renderIdentity(m)) + sb.WriteByte('\n') + sb.WriteString(renderDivider(m.termW, m.st)) + sb.WriteByte('\n') + sb.WriteString(renderDeliveriesHeader(m.termW, m.st)) + sb.WriteByte('\n') + sb.WriteString(m.vp.View()) + sb.WriteByte('\n') + sb.WriteString(renderDivider(m.termW, m.st)) + sb.WriteByte('\n') + sb.WriteString(renderKeybindBar(m)) + return sb.String() +} + +func (m Model) renderHelpScreen() string { + var sb strings.Builder + sb.WriteString(renderTitle(m)) + sb.WriteByte('\n') + sb.WriteString(renderHelpOverlay(m)) + sb.WriteByte('\n') + sb.WriteString(renderDivider(m.termW, m.st)) + sb.WriteByte('\n') + sb.WriteString(renderKeybindBar(m)) + return sb.String() +} + +func renderTitle(m Model) string { + left := m.st.title.Render("hooksctl " + Version) + hint := m.st.dim.Render("? help") + gap := m.termW - lipgloss.Width(left) - lipgloss.Width(hint) + if gap < 0 { + gap = 0 + } + return left + strings.Repeat(" ", gap) + hint +} + +func renderIdentity(m Model) string { + pill := sessionPill(m) + uptime := formatUptime(time.Since(m.session.UptimeStart)) + statusLine := pill + m.st.dim.Render(" uptime "+uptime) + route := m.st.forwardURL.Render(m.session.ForwardURL) + + m.st.dim.Render(" → ") + + m.st.targetURL.Render(m.session.TargetURL) + + if m.termH < 24 { + return statusLine + "\n" + route + } + + email := m.st.dim.Render("account ") + m.session.Email + scopeStr := strings.Join(m.session.Scopes, ", ") + token := m.st.dim.Render("token ") + + m.st.tokenHighlight.Render(m.session.TokenPrefix+"…"+m.session.TokenSuffix) + + m.st.dim.Render(" "+scopeStr) + + return statusLine + "\n" + email + "\n" + route + "\n" + token +} + +func sessionPill(m Model) string { + switch m.session.State { + case StateOnline: + return m.st.statusOnline.Render("● online") + case StateReconnecting: + rc := "" + if m.session.ReconnectCount > 0 { + rc = fmt.Sprintf(" (×%d)", m.session.ReconnectCount) + } + return m.st.statusReconnecting.Render("● reconnecting" + rc) + case StatePaused: + return m.st.statusPaused.Render("● paused") + default: + return m.st.statusOffline.Render("● offline") + } +} + +func renderDivider(w int, st tuiStyles) string { + return st.divider.Render(strings.Repeat("─", w)) +} + +func renderDeliveriesHeader(w int, st tuiStyles) string { + left := "DELIVERIES" + right := st.dim.Render("newest ↓") + gap := w - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 0 { + gap = 0 + } + return left + strings.Repeat(" ", gap) + right +} + +// renderDeliveryRow renders a single delivery row with fixed-width columns. +// Column drop thresholds: suffix <80, size <73, latency <65. +func renderDeliveryRow(d Delivery, termW int, st tuiStyles) string { + ts := d.RecvAt.Format("15:04:05.000") + method := fmt.Sprintf("%-6s", truncate(d.Method, 6)) + source := fmt.Sprintf("%-18s", truncate(d.Source, 18)) + + var statusStr string + if d.InFlight { + statusStr = st.statusMagenta.Render("⇡ in flight") + } else if d.Status == 0 { + statusStr = fmt.Sprintf("%-4s", "—") + } else { + statusStr = st.statusStyle(d.Status).Render(fmt.Sprintf("%-4d", d.Status)) + } + + // optional right-side columns + var rightParts []string + if termW >= 65 { + rightParts = append(rightParts, st.dim.Render(fmt.Sprintf("%6dms", d.DurationMS))) + } + if termW >= 73 { + rightParts = append(rightParts, st.dim.Render(fmt.Sprintf("%6dB", d.SizeBytes))) + } + if termW >= 80 && d.Suffix != "" { + rightParts = append(rightParts, st.dim.Render(truncate(d.Suffix, 20))) + } + + rightStr := strings.Join(rightParts, " ") + + // fixed prefix width (without ANSI): ts(12) + sp(1) + method(6) + sp(1) + source(18) + sp(1) + status + // status visible width: 4 for code, 11 for "⇡ in flight" + statusVisW := 4 + if d.InFlight { + statusVisW = lipgloss.Width(statusStr) + } + prefixVisW := 12 + 1 + 6 + 1 + 18 + 1 + statusVisW + + rightVisW := 0 + if rightStr != "" { + rightVisW = lipgloss.Width(rightStr) + 1 // leading space + } + + pathWidth := termW - prefixVisW - 1 - rightVisW + if pathWidth < 12 { + pathWidth = 12 + } + path := fmt.Sprintf("%-*s", pathWidth, truncate(d.Path, pathWidth)) + + prefix := st.dim.Render(ts) + " " + method + " " + source + " " + statusStr + if rightStr == "" { + return prefix + " " + path + } + return prefix + " " + path + " " + rightStr +} + +func renderKeybindBar(m Model) string { + if m.toastMsg != "" && time.Now().Before(m.toastExpiry) { + return m.st.toast.Render(m.toastMsg) + } + chip := func(k, label string) string { + return m.st.keybindChip.Render(" "+k+" ") + " " + label + } + parts := []string{ + chip("c", "copy URL"), + chip("p", "pause"), + chip("?", "help"), + chip("q", "quit"), + } + return strings.Join(parts, " ") +} + +func renderHelpOverlay(m Model) string { + var sb strings.Builder + sb.WriteString("┌── Help ──────────────────────────────┐\n") + sb.WriteString("│ │\n") + sb.WriteString("│ c copy forwarding URL │\n") + sb.WriteString("│ p pause / resume │\n") + sb.WriteString("│ ?/esc toggle help │\n") + sb.WriteString("│ q/^C quit │\n") + sb.WriteString("│ ↑↓ scroll │\n") + sb.WriteString("│ │\n") + fmt.Fprintf(&sb, "│ hooksctl %-27s │\n", Version) + sb.WriteString("│ │\n") + sb.WriteString("└──────────────────────────────────────┘") + return sb.String() +} + +// fixedHeaderRows returns the number of rows consumed by non-viewport layout. +func fixedHeaderRows(termH int) int { + identityRows := 4 + if termH < 24 { + identityRows = 2 + } + // title + identity + divider + deliveries-header + divider + footer + return 1 + identityRows + 1 + 1 + 1 + 1 +} + +// viewportHeight returns the number of rows available for the delivery viewport. +func viewportHeight(termH, headerRows int) int { + h := termH - headerRows + if h < 0 { + return 0 + } + return h +} + +// truncate shortens s to at most max runes, replacing the last rune with "…". +func truncate(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + if max <= 1 { + return "…" + } + return string(runes[:max-1]) + "…" +} + +func formatUptime(d time.Duration) string { + if d < 0 { + d = 0 + } + h := int(d.Hours()) + mn := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + if h > 0 { + return fmt.Sprintf("%dh%02dm%02ds", h, mn, s) + } + return fmt.Sprintf("%dm%02ds", mn, s) +} diff --git a/openspec/changes/hooksctl-tui/tasks.md b/openspec/changes/hooksctl-tui/tasks.md index 130ed69..e80bafb 100644 --- a/openspec/changes/hooksctl-tui/tasks.md +++ b/openspec/changes/hooksctl-tui/tasks.md @@ -1,83 +1,83 @@ ## 1. Dependencies & Module Setup -- [ ] 1.1 Add `github.com/charmbracelet/bubbletea`, `github.com/charmbracelet/bubbles`, `github.com/charmbracelet/lipgloss`, and `github.com/atotto/clipboard` to `go.mod` via `go get` -- [ ] 1.2 Run `make tidy` and commit updated `go.mod` / `go.sum` -- [ ] 1.3 Create `internal/tui/` package directory with a stub `doc.go` +- [x] 1.1 Add `github.com/charmbracelet/bubbletea`, `github.com/charmbracelet/bubbles`, `github.com/charmbracelet/lipgloss`, and `github.com/atotto/clipboard` to `go.mod` via `go get` +- [x] 1.2 Run `make tidy` and commit updated `go.mod` / `go.sum` +- [x] 1.3 Create `internal/tui/` package directory with a stub `doc.go` ## 2. Message Types & Domain Types -- [ ] 2.1 Define `SessionState` type (online / reconnecting / paused / offline) and `SessionInfo` struct (state, reconnect count, uptime start, account email, forwarding route, token prefix/suffix, scopes) -- [ ] 2.2 Define `Delivery` struct (id, recv_at, method, path, source, status, duration_ms, size_bytes, suffix, in_flight bool) -- [ ] 2.3 Define Bubble Tea message types: `DeliveryReceivedMsg`, `DeliveryCompletedMsg`, `SessionStateMsg`, `tickMsg`, `clipboardCopiedMsg` +- [x] 2.1 Define `SessionState` type (online / reconnecting / paused / offline) and `SessionInfo` struct (state, reconnect count, uptime start, account email, forwarding route, token prefix/suffix, scopes) +- [x] 2.2 Define `Delivery` struct (id, recv_at, method, path, source, status, duration_ms, size_bytes, suffix, in_flight bool) +- [x] 2.3 Define Bubble Tea message types: `DeliveryReceivedMsg`, `DeliveryCompletedMsg`, `SessionStateMsg`, `tickMsg`, `clipboardCopiedMsg` ## 3. Lip Gloss Style Definitions -- [ ] 3.1 Define color token constants matching the spec (`termGreen`, `termAmber`, `termRed`, `termBlue`, `termMagenta`, `termCyan`, `termFg`, `termDim`) using `lipgloss.Color` with `AdaptiveColor` light/dark pairs -- [ ] 3.2 Define named styles: `styleTitle`, `styleDim`, `styleStatusOnline`, `styleStatusReconnecting`, `styleStatusPaused`, `styleForwardURL`, `styleTargetURL`, `styleTokenHighlight`, `styleDivider`, `styleKeybind`, `styleToast` -- [ ] 3.3 Define status-code color function `statusStyle(code int) lipgloss.Style` +- [x] 3.1 Define color token constants matching the spec (`termGreen`, `termAmber`, `termRed`, `termBlue`, `termMagenta`, `termCyan`, `termFg`, `termDim`) using `lipgloss.Color` with `AdaptiveColor` light/dark pairs +- [x] 3.2 Define named styles: `styleTitle`, `styleDim`, `styleStatusOnline`, `styleStatusReconnecting`, `styleStatusPaused`, `styleForwardURL`, `styleTargetURL`, `styleTokenHighlight`, `styleDivider`, `styleKeybind`, `styleToast` +- [x] 3.3 Define status-code color function `statusStyle(code int) lipgloss.Style` ## 4. Core Model -- [ ] 4.1 Define `Model` struct with fields: `session SessionInfo`, `deliveries []Delivery` (ring buffer), `viewport viewport.Model`, `help help.Model`, `showHelp bool`, `atBottom bool`, `toastMsg string`, `toastExpiry time.Time`, `termW int`, `termH int`, `keys keyMap` -- [ ] 4.2 Implement `New(session SessionInfo) Model` constructor that initialises the viewport and help model -- [ ] 4.3 Implement `Init() tea.Cmd` — returns `tea.Batch(tickCmd(), tea.RequestBackgroundColor)` -- [ ] 4.4 Implement ring-buffer append helper `appendDelivery(m *Model, d Delivery)` that evicts oldest when len >= 500 +- [x] 4.1 Define `Model` struct with fields: `session SessionInfo`, `deliveries []Delivery` (ring buffer), `viewport viewport.Model`, `help help.Model`, `showHelp bool`, `atBottom bool`, `toastMsg string`, `toastExpiry time.Time`, `termW int`, `termH int`, `keys keyMap` +- [x] 4.2 Implement `New(session SessionInfo) Model` constructor that initialises the viewport and help model +- [x] 4.3 Implement `Init() tea.Cmd` — returns `tea.Batch(tickCmd(), tea.RequestBackgroundColor)` +- [x] 4.4 Implement ring-buffer append helper `appendDelivery(m *Model, d Delivery)` that evicts oldest when len >= 500 ## 5. Key Bindings -- [ ] 5.1 Define `keyMap` struct with `key.Binding` fields: `copyURL`, `pause`, `help`, `quit` -- [ ] 5.2 Implement `ShortHelp()` and `FullHelp()` on `keyMap` for the bubbles `help.Model` -- [ ] 5.3 Wire key bindings in `Update()` — `c`, `p`, `?`, `q`, `ctrl+c` +- [x] 5.1 Define `keyMap` struct with `key.Binding` fields: `copyURL`, `pause`, `help`, `quit` +- [x] 5.2 Implement `ShortHelp()` and `FullHelp()` on `keyMap` for the bubbles `help.Model` +- [x] 5.3 Wire key bindings in `Update()` — `c`, `p`, `?`, `q`, `ctrl+c` ## 6. Update Logic -- [ ] 6.1 Handle `tea.WindowSizeMsg` — recompute `termW`, `termH`, viewport height via `viewportHeight()`, re-render content -- [ ] 6.2 Handle `tea.BackgroundColorMsg` — rebuild Lip Gloss styles for light/dark -- [ ] 6.3 Handle `DeliveryReceivedMsg` — append to ring buffer, scroll to bottom if `atBottom`, rebuild viewport content -- [ ] 6.4 Handle `DeliveryCompletedMsg` — find matching in-flight row by ID, update status/latency/suffix, rebuild viewport content -- [ ] 6.5 Handle `SessionStateMsg` — update `m.session`, re-render header -- [ ] 6.6 Handle `tickMsg` — refresh uptime display, expire toast if past `toastExpiry`, return next tick command -- [ ] 6.7 Handle `clipboardCopiedMsg` — set `toastMsg` and `toastExpiry = time.Now().Add(1.5s)` -- [ ] 6.8 Implement quit: `q`/`^C` cancels the SSE consumer context and calls `tea.Quit` immediately -- [ ] 6.9 Implement visual-only pause/resume: toggle `m.session.State` between paused and online; no back-channel to the SSE goroutine +- [x] 6.1 Handle `tea.WindowSizeMsg` — recompute `termW`, `termH`, viewport height via `viewportHeight()`, re-render content +- [x] 6.2 Handle `tea.BackgroundColorMsg` — rebuild Lip Gloss styles for light/dark +- [x] 6.3 Handle `DeliveryReceivedMsg` — append to ring buffer, scroll to bottom if `atBottom`, rebuild viewport content +- [x] 6.4 Handle `DeliveryCompletedMsg` — find matching in-flight row by ID, update status/latency/suffix, rebuild viewport content +- [x] 6.5 Handle `SessionStateMsg` — update `m.session`, re-render header +- [x] 6.6 Handle `tickMsg` — refresh uptime display, expire toast if past `toastExpiry`, return next tick command +- [x] 6.7 Handle `clipboardCopiedMsg` — set `toastMsg` and `toastExpiry = time.Now().Add(1.5s)` +- [x] 6.8 Implement quit: `q`/`^C` cancels the SSE consumer context and calls `tea.Quit` immediately +- [x] 6.9 Implement visual-only pause/resume: toggle `m.session.State` between paused and online; no back-channel to the SSE goroutine ## 7. View / Rendering -- [ ] 7.1 Implement `renderTitle(m Model) string` — `hooksctl ` left cyan-bold, right-aligned dim help hint -- [ ] 7.2 Implement `renderIdentity(m Model) string` — 4-row key/value block (session pill, account, forwarding route, token); collapse to 2 rows when `termH < 24` -- [ ] 7.3 Implement `renderDivider(w int) string` — `strings.Repeat("─", w)` in dim style -- [ ] 7.4 Implement `renderDeliveriesHeader() string` — "DELIVERIES" small-caps left, "newest ↓" dim right -- [ ] 7.5 Implement `renderDeliveryRow(d Delivery, termW int) string` — fixed-width columns with column-drop thresholds: suffix dropped below 80 cols, size also dropped below 73, latency also dropped below 65 -- [ ] 7.6 Implement `renderKeybindBar(m Model) string` — inverted key chips + labels; when toast is active, render toast text in place of keybind labels for 1.5 s, then restore -- [ ] 7.7 Implement `renderHelpOverlay(m Model) string` — modal box listing all bindings + version info -- [ ] 7.8 Implement `View() string` — compose title + identity + divider + deliveries header + viewport + divider + keybind bar; overlay help modal when `showHelp` -- [ ] 7.9 Implement `viewportHeight(termH, headerRows int) int` and verify off-by-one with a unit test +- [x] 7.1 Implement `renderTitle(m Model) string` — `hooksctl ` left cyan-bold, right-aligned dim help hint +- [x] 7.2 Implement `renderIdentity(m Model) string` — 4-row key/value block (session pill, account, forwarding route, token); collapse to 2 rows when `termH < 24` +- [x] 7.3 Implement `renderDivider(w int) string` — `strings.Repeat("─", w)` in dim style +- [x] 7.4 Implement `renderDeliveriesHeader() string` — "DELIVERIES" small-caps left, "newest ↓" dim right +- [x] 7.5 Implement `renderDeliveryRow(d Delivery, termW int) string` — fixed-width columns with column-drop thresholds: suffix dropped below 80 cols, size also dropped below 73, latency also dropped below 65 +- [x] 7.6 Implement `renderKeybindBar(m Model) string` — inverted key chips + labels; when toast is active, render toast text in place of keybind labels for 1.5 s, then restore +- [x] 7.7 Implement `renderHelpOverlay(m Model) string` — modal box listing all bindings + version info +- [x] 7.8 Implement `View() string` — compose title + identity + divider + deliveries header + viewport + divider + keybind bar; overlay help modal when `showHelp` +- [x] 7.9 Implement `viewportHeight(termH, headerRows int) int` and verify off-by-one with a unit test ## 8. Commands -- [ ] 8.1 Implement `tickCmd() tea.Cmd` — fires `tickMsg{time.Now()}` after 1 s using `tea.Tick` -- [ ] 8.2 Implement `copyURLCmd(url string) tea.Cmd` — calls `clipboard.WriteAll(url)`, returns `clipboardCopiedMsg` or error toast msg +- [x] 8.1 Implement `tickCmd() tea.Cmd` — fires `tickMsg{time.Now()}` after 1 s using `tea.Tick` +- [x] 8.2 Implement `copyURLCmd(url string) tea.Cmd` — calls `clipboard.WriteAll(url)`, returns `clipboardCopiedMsg` or error toast msg ## 9. TTY Detection & `forward` Command Wiring -- [ ] 9.1 Add `golang.org/x/term` import to `cmd/hooksctl/forward.go` (already likely available transitively; confirm in `go.mod`) -- [ ] 9.2 In `forward.go` run loop: detect `term.IsTerminal(int(os.Stdout.Fd()))` before starting SSE consumer -- [ ] 9.3 If TTY: create `tui.Model`, create `tea.NewProgram(model, tea.WithAltScreen())`, run SSE consumer in a goroutine that calls `p.Send(tui.DeliveryReceivedMsg{...})` and `p.Send(tui.SessionStateMsg{...})` on events -- [ ] 9.4 If not TTY: keep existing structured-log path unchanged -- [ ] 9.5 Wire `defer p.RestoreTerminal()` for panic safety +- [x] 9.1 Add `golang.org/x/term` import to `cmd/hooksctl/forward.go` (already likely available transitively; confirm in `go.mod`) +- [x] 9.2 In `forward.go` run loop: detect `term.IsTerminal(int(os.Stdout.Fd()))` before starting SSE consumer +- [x] 9.3 If TTY: create `tui.Model`, create `tea.NewProgram(model, tea.WithAltScreen())`, run SSE consumer in a goroutine that calls `p.Send(tui.DeliveryReceivedMsg{...})` and `p.Send(tui.SessionStateMsg{...})` on events +- [x] 9.4 If not TTY: keep existing structured-log path unchanged +- [x] 9.5 Wire `defer p.RestoreTerminal()` for panic safety ## 10. Tests -- [ ] 10.1 Unit test `viewportHeight()` for standard, short-terminal, and edge-case inputs -- [ ] 10.2 Unit test `renderDeliveryRow()` for 2xx/4xx/5xx color paths and column-drop at < 80 cols -- [ ] 10.3 Unit test `appendDelivery()` ring-buffer eviction at cap 500 -- [ ] 10.4 Unit test `Update()` for `DeliveryReceivedMsg` (appends, scroll behavior) and `DeliveryCompletedMsg` (patches in-flight row) -- [ ] 10.5 Unit test quit: `q`/`^C` produces `tea.Quit` immediately regardless of in-flight state -- [ ] 10.6 Run `make lint && make test` to confirm no regressions +- [x] 10.1 Unit test `viewportHeight()` for standard, short-terminal, and edge-case inputs +- [x] 10.2 Unit test `renderDeliveryRow()` for 2xx/4xx/5xx color paths and column-drop at < 80 cols +- [x] 10.3 Unit test `appendDelivery()` ring-buffer eviction at cap 500 +- [x] 10.4 Unit test `Update()` for `DeliveryReceivedMsg` (appends, scroll behavior) and `DeliveryCompletedMsg` (patches in-flight row) +- [x] 10.5 Unit test quit: `q`/`^C` produces `tea.Quit` immediately regardless of in-flight state +- [x] 10.6 Run `make lint && make test` to confirm no regressions ## 11. Smoke Test & Cleanup - [ ] 11.1 Run `make dev` and `hooksctl forward render` in a real terminal; verify all regions render correctly - [ ] 11.2 Test resize by dragging terminal window; confirm column drop and identity collapse - [ ] 11.3 Test `c` (copy URL + toast), `p` (pause/resume pill), `?` (help overlay), `q` (quit) -- [ ] 11.4 Remove stub `doc.go` if package has real files; ensure no dead code +- [x] 11.4 Remove stub `doc.go` if package has real files; ensure no dead code From 0c63459762d37075bb0f81bc082f0a19d82f7b75 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Mon, 11 May 2026 23:21:28 -0700 Subject: [PATCH 4/7] adds hooksctl binary to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index eb415a2..3782f09 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ hooks.yaml .env .claude/scheduled_tasks.lock .playwright-mcp/ +hooksctl \ No newline at end of file From 370167863074edbc075f6433da73f3c53625c7de Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Tue, 12 May 2026 09:14:48 -0700 Subject: [PATCH 5/7] simplify(tui): clean up TUI code quality after review - Extract parseEventPayload and streamFromCursorWith to eliminate duplicated SSE parsing and JSON/base64 logic across TUI and non-TUI paths - Move stderr logging out of parseEventPayload so writes don't corrupt the alt-screen during TUI operation; non-TUI forwardOne logs instead - Add defer prog.RestoreTerminal() panic-safety net in runWithTUI - Auto-send QuitMsg when server closes stream cleanly, matching non-TUI exit behavior - Log reconnect errors to stderr before backoff delay - Fix esc key to only dismiss the help overlay, not open it (separate dismiss binding) - Add cancelled suffix for context-cancelled in-flight deliveries - Remove dead StatePaused constant and help.Model field - Remove dead stderr write from copyURLCmd (toast is the TUI feedback channel) - Flatten nested if in runWithTUI clean-close branch - Use key binding Help() values in renderKeybindBar instead of hardcoded strings - Add renderHelpOverlay sync comment - Add unit tests: parseEventPayload, tokenFingerprint, streamFromCursorWith errSkipEvent path - Add tests: copy URL key, esc open/close behavior, scroll position preserved on new delivery - Fix TestUpdate_ToastLifecycle to not block 1.5s on the one-shot timer --- cmd/hooksctl/forward.go | 207 ++++++++---------- cmd/hooksctl/forward_unit_test.go | 135 ++++++++++++ go.mod | 10 +- go.sum | 4 + internal/tui/commands.go | 8 +- internal/tui/keys.go | 14 +- internal/tui/model.go | 32 ++- internal/tui/styles.go | 4 - internal/tui/tui_test.go | 353 ++++++++++++++++++++++++++++-- internal/tui/types.go | 13 +- internal/tui/view.go | 20 +- 11 files changed, 617 insertions(+), 183 deletions(-) create mode 100644 cmd/hooksctl/forward_unit_test.go diff --git a/cmd/hooksctl/forward.go b/cmd/hooksctl/forward.go index 32f3d0a..8afbe37 100644 --- a/cmd/hooksctl/forward.go +++ b/cmd/hooksctl/forward.go @@ -30,6 +30,37 @@ import ( // own context from os signals. var forwardTestCtx context.Context +// errSkipEvent is returned when an event payload is permanently malformed. +// The caller advances the cursor past the broken event rather than reconnecting. +var errSkipEvent = errors.New("skip event") + +type parsedEvent struct { + DeliveryID string + Headers map[string]string + Body []byte +} + +func parseEventPayload(msg map[string]string) (parsedEvent, error) { + var raw struct { + DeliveryID string `json:"delivery_id"` + ProviderTimestamp time.Time `json:"provider_timestamp"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` + } + if err := json.Unmarshal([]byte(msg["data"]), &raw); err != nil { + return parsedEvent{}, fmt.Errorf("%w: parse: %w", errSkipEvent, err) + } + bodyBytes, err := base64.StdEncoding.DecodeString(raw.Body) + if err != nil { + return parsedEvent{}, fmt.Errorf("%w: decode: %w", errSkipEvent, err) + } + delivID := raw.DeliveryID + if delivID == "" { + delivID = msg["id"] + } + return parsedEvent{DeliveryID: delivID, Headers: raw.Headers, Body: bodyBytes}, nil +} + func cmdForward(g globals, args []string) int { fs := newFlagSet("forward") to := fs.String("to", "", "local URL to POST every event to") @@ -100,7 +131,7 @@ func cmdForward(g globals, args []string) int { fmt.Fprintln(os.Stderr, "forward:", err) return 1 } - // Backoff capped at 60s; mirrors push policy. + // Fixed random reconnect delay in [500ms, 2.5s); see attemptBackoff for per-delivery retry. delay := backoff() fmt.Fprintf(os.Stderr, "forward: %v; reconnecting in %s\n", err, delay) select { @@ -130,43 +161,42 @@ func runWithTUI(ctx context.Context, cancel context.CancelFunc, g globals, sourc model := tui.New(baseSession, cancel) prog := tea.NewProgram(model) + defer func() { _ = prog.RestoreTerminal() }() + errCh := make(chan error, 1) go func() { reconnectCount := 0 for { - prog.Send(tui.SessionStateMsg{Info: tui.SessionInfo{ - State: tui.StateOnline, - ReconnectCount: reconnectCount, - UptimeStart: baseSession.UptimeStart, - ForwardURL: baseSession.ForwardURL, - TargetURL: baseSession.TargetURL, - TokenPrefix: prefix, - TokenSuffix: suffix, - Scopes: baseSession.Scopes, - }}) + info := baseSession + info.ReconnectCount = reconnectCount + prog.Send(tui.SessionStateMsg{Info: info}) err := streamFromCursorTUI(ctx, prog, g, subscribeToken, source, cursor, cursorPath, to, cli, exitOnError) + if err == nil && ctx.Err() == nil { + // Server closed the stream cleanly; mirror the non-TUI auto-exit behavior. + info := baseSession + info.State = tui.StateOffline + prog.Send(tui.SessionStateMsg{Info: info}) + prog.Send(tui.QuitMsg{}) + return + } if err == nil || ctx.Err() != nil { return } if exitOnError { - cancel() + errCh <- err + prog.Send(tui.QuitMsg{}) return } reconnectCount++ - prog.Send(tui.SessionStateMsg{Info: tui.SessionInfo{ - State: tui.StateReconnecting, - ReconnectCount: reconnectCount, - UptimeStart: baseSession.UptimeStart, - ForwardURL: baseSession.ForwardURL, - TargetURL: baseSession.TargetURL, - TokenPrefix: prefix, - TokenSuffix: suffix, - Scopes: baseSession.Scopes, - }}) + info = baseSession + info.State = tui.StateReconnecting + info.ReconnectCount = reconnectCount + prog.Send(tui.SessionStateMsg{Info: info}) + fmt.Fprintf(os.Stderr, "forward: %v; reconnecting\n", err) d := backoff() select { case <-ctx.Done(): @@ -176,93 +206,39 @@ func runWithTUI(ctx context.Context, cancel context.CancelFunc, g globals, sourc } }() - defer prog.RestoreTerminal() //nolint:errcheck if _, err := prog.Run(); err != nil { fmt.Fprintln(os.Stderr, "forward:", err) return 1 } - return 0 + select { + case err := <-errCh: + fmt.Fprintln(os.Stderr, "forward:", err) + return 1 + default: + return 0 + } } -// streamFromCursorTUI is like streamFromCursor but sends delivery events to prog. func streamFromCursorTUI(ctx context.Context, prog *tea.Program, g globals, bearer, source string, cursor *int64, cursorPath, to string, cli *http.Client, exitOnError bool) error { - endpoint := fmt.Sprintf("%s/subscribe/%s?since=%d", strings.TrimRight(g.Server, "/"), source, *cursor) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+bearer) - req.Header.Set("Accept", "text/event-stream") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("subscribe returned %d", resp.StatusCode) - } - - scanner := bufio.NewScanner(resp.Body) - scanner.Buffer(make([]byte, 1<<20), 1<<20) - current := map[string]string{} - for scanner.Scan() { - line := scanner.Text() - switch { - case line == "": - if len(current) == 0 { - continue - } - seq, err := strconv.ParseInt(current["id"], 10, 64) - if err != nil { - current = map[string]string{} - continue - } - if err := forwardOneTUI(ctx, prog, cli, to, current, source, exitOnError); err != nil { - return err - } - *cursor = seq - saveCursor(cursorPath, seq) - current = map[string]string{} - case strings.HasPrefix(line, ":"): - // keepalive - default: - if i := strings.Index(line, ":"); i >= 0 { - current[line[:i]] = strings.TrimPrefix(line[i+1:], " ") - } - } - } - return scanner.Err() + return streamFromCursorWith(ctx, g, bearer, source, cursor, cursorPath, func(ctx context.Context, msg map[string]string) error { + return forwardOneTUI(ctx, prog, cli, to, msg, source, exitOnError) + }) } -// forwardOneTUI forwards a single event and notifies prog of delivery start/completion. func forwardOneTUI(ctx context.Context, prog *tea.Program, cli *http.Client, to string, msg map[string]string, source string, exitOnError bool) error { - var p struct { - DeliveryID string `json:"delivery_id"` - ProviderTimestamp time.Time `json:"provider_timestamp"` - Headers map[string]string `json:"headers"` - Body string `json:"body"` - } - if err := json.Unmarshal([]byte(msg["data"]), &p); err != nil { - return fmt.Errorf("parse event: %w", err) - } - bodyBytes, err := base64.StdEncoding.DecodeString(p.Body) + p, err := parseEventPayload(msg) if err != nil { - return fmt.Errorf("decode body: %w", err) - } - - delivID := p.DeliveryID - if delivID == "" { - delivID = msg["id"] + return err } prog.Send(tui.DeliveryReceivedMsg{Delivery: tui.Delivery{ - ID: delivID, + ID: p.DeliveryID, RecvAt: time.Now(), Method: "POST", Path: "/" + source, Source: source, InFlight: true, - SizeBytes: int64(len(bodyBytes)), + SizeBytes: int64(len(p.Body)), }}) start := time.Now() @@ -270,7 +246,7 @@ func forwardOneTUI(ctx context.Context, prog *tea.Program, cli *http.Client, to var forwardErr error for attempt := 0; ; attempt++ { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, to, bytes.NewReader(bodyBytes)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, to, bytes.NewReader(p.Body)) if err != nil { forwardErr = err break @@ -317,15 +293,16 @@ func forwardOneTUI(ctx context.Context, prog *tea.Program, cli *http.Client, to } suffix := "" - if forwardErr != nil && ctx.Err() == nil { - suffix = "err" - if finalStatus == 0 { - finalStatus = 0 + if forwardErr != nil { + if ctx.Err() != nil { + suffix = "cancelled" + } else { + suffix = "err" } } prog.Send(tui.DeliveryCompletedMsg{ - ID: delivID, + ID: p.DeliveryID, Status: finalStatus, DurationMS: time.Since(start).Milliseconds(), Suffix: suffix, @@ -346,7 +323,9 @@ func tokenFingerprint(token string) (prefix, suffix string) { return token, "" } -func streamFromCursor(ctx context.Context, g globals, bearer, source string, cursor *int64, cursorPath, to string, cli *http.Client, exitOnError bool) error { +// streamFromCursorWith opens an SSE subscription and calls handle for each event. +// Malformed events that return errSkipEvent have their cursor advanced and are skipped. +func streamFromCursorWith(ctx context.Context, g globals, bearer, source string, cursor *int64, cursorPath string, handle func(context.Context, map[string]string) error) error { endpoint := fmt.Sprintf("%s/subscribe/%s?since=%d", strings.TrimRight(g.Server, "/"), source, *cursor) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { @@ -375,15 +354,21 @@ func streamFromCursor(ctx context.Context, g globals, bearer, source string, cur } seq, err := strconv.ParseInt(current["id"], 10, 64) if err != nil { - current = map[string]string{} + clear(current) continue } - if err := forwardOne(ctx, cli, to, current, exitOnError); err != nil { + if err := handle(ctx, current); err != nil { + if errors.Is(err, errSkipEvent) { + *cursor = seq + saveCursor(cursorPath, seq) + clear(current) + continue + } return err } *cursor = seq saveCursor(cursorPath, seq) - current = map[string]string{} + clear(current) case strings.HasPrefix(line, ":"): // keepalive default: @@ -395,23 +380,21 @@ func streamFromCursor(ctx context.Context, g globals, bearer, source string, cur return scanner.Err() } +func streamFromCursor(ctx context.Context, g globals, bearer, source string, cursor *int64, cursorPath, to string, cli *http.Client, exitOnError bool) error { + return streamFromCursorWith(ctx, g, bearer, source, cursor, cursorPath, func(ctx context.Context, msg map[string]string) error { + return forwardOne(ctx, cli, to, msg, exitOnError) + }) +} + func forwardOne(ctx context.Context, cli *http.Client, to string, msg map[string]string, exitOnError bool) error { - var p struct { - DeliveryID string `json:"delivery_id"` - ProviderTimestamp time.Time `json:"provider_timestamp"` - Headers map[string]string `json:"headers"` - Body string `json:"body"` - } - if err := json.Unmarshal([]byte(msg["data"]), &p); err != nil { - return fmt.Errorf("parse event: %w", err) - } - bodyBytes, err := base64.StdEncoding.DecodeString(p.Body) + p, err := parseEventPayload(msg) if err != nil { - return fmt.Errorf("decode body: %w", err) + fmt.Fprintf(os.Stderr, "forward: malformed event seq=%s: %v\n", msg["id"], err) + return err } for attempt := 0; ; attempt++ { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, to, bytes.NewReader(bodyBytes)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, to, bytes.NewReader(p.Body)) if err != nil { return err } diff --git a/cmd/hooksctl/forward_unit_test.go b/cmd/hooksctl/forward_unit_test.go new file mode 100644 index 0000000..f07c6dd --- /dev/null +++ b/cmd/hooksctl/forward_unit_test.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +// --- parseEventPayload --- + +func TestParseEventPayload(t *testing.T) { + encode := func(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } + + t.Run("happy path", func(t *testing.T) { + raw, _ := json.Marshal(map[string]any{ + "delivery_id": "d1", + "headers": map[string]string{"Content-Type": "application/json"}, + "body": encode(`{"event":"test"}`), + }) + p, err := parseEventPayload(map[string]string{"data": string(raw), "id": "42"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.DeliveryID != "d1" { + t.Errorf("DeliveryID = %q; want d1", p.DeliveryID) + } + if string(p.Body) != `{"event":"test"}` { + t.Errorf("Body = %q; want {\"event\":\"test\"}", p.Body) + } + if p.Headers["Content-Type"] != "application/json" { + t.Errorf("Headers[Content-Type] = %q", p.Headers["Content-Type"]) + } + }) + + t.Run("malformed JSON returns errSkipEvent", func(t *testing.T) { + _, err := parseEventPayload(map[string]string{"data": "not-json", "id": "1"}) + if !errors.Is(err, errSkipEvent) { + t.Errorf("want errSkipEvent, got %v", err) + } + }) + + t.Run("invalid base64 body returns errSkipEvent", func(t *testing.T) { + raw, _ := json.Marshal(map[string]any{ + "delivery_id": "d3", + "headers": map[string]string{}, + "body": "not!!valid!!base64", + }) + _, err := parseEventPayload(map[string]string{"data": string(raw), "id": "3"}) + if !errors.Is(err, errSkipEvent) { + t.Errorf("want errSkipEvent, got %v", err) + } + }) + + t.Run("missing delivery_id falls back to SSE id", func(t *testing.T) { + raw, _ := json.Marshal(map[string]any{ + "headers": map[string]string{}, + "body": encode("body"), + }) + p, err := parseEventPayload(map[string]string{"data": string(raw), "id": "99"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.DeliveryID != "99" { + t.Errorf("DeliveryID fallback = %q; want 99", p.DeliveryID) + } + }) +} + +// --- tokenFingerprint --- + +func TestTokenFingerprint(t *testing.T) { + tests := []struct { + token string + wantPrefix string + wantSuffix string + }{ + {"abcdefghij1234567890", "abcdef", "890"}, // > 9 runes + {"abcde", "abc", "cde"}, // > 3, <= 9 runes + {"abc", "abc", ""}, // exactly 3: full token, empty suffix + {"ab", "ab", ""}, // <= 3 runes + {"", "", ""}, // empty + } + for _, tc := range tests { + prefix, suffix := tokenFingerprint(tc.token) + if prefix != tc.wantPrefix || suffix != tc.wantSuffix { + t.Errorf("tokenFingerprint(%q) = (%q, %q); want (%q, %q)", + tc.token, prefix, suffix, tc.wantPrefix, tc.wantSuffix) + } + } +} + +// --- streamFromCursorWith errSkipEvent handling --- + +func TestStreamFromCursorWith_SkipsErrSkipEvent(t *testing.T) { + // SSE server: first event triggers errSkipEvent from handle, second is valid. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + // Event id=1: handle will return errSkipEvent for this one. + fmt.Fprint(w, "id: 1\nevent: render\ndata: bad\n\n") + // Event id=2: handled normally. + fmt.Fprint(w, "id: 2\nevent: render\ndata: good\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + })) + t.Cleanup(srv.Close) + + var handled []string + cursor := int64(0) + cursorPath := filepath.Join(t.TempDir(), "cursor") + g := globals{Server: srv.URL} + + _ = streamFromCursorWith(context.Background(), g, "tok", "render", &cursor, cursorPath, + func(_ context.Context, msg map[string]string) error { + if msg["id"] == "1" { + return errSkipEvent + } + handled = append(handled, msg["id"]) + return nil + }) + + if len(handled) != 1 || handled[0] != "2" { + t.Errorf("handled = %v; want [2] (event 1 should be skipped)", handled) + } + if cursor != 2 { + t.Errorf("cursor = %d; want 2 (advanced past both events)", cursor) + } +} diff --git a/go.mod b/go.mod index d1ee91a..b574706 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,11 @@ module github.com/onebusaway/hooks go 1.26.0 require ( + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.6 + charm.land/lipgloss/v2 v2.0.3 + github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/x/term v0.2.2 github.com/google/uuid v1.6.0 github.com/standard-webhooks/standard-webhooks/libraries v0.0.0-20260508151727-1282bb917829 golang.org/x/crypto v0.50.0 @@ -14,9 +19,6 @@ require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect cel.dev/expr v0.25.1 // indirect - charm.land/bubbles/v2 v2.1.0 // indirect - charm.land/bubbletea/v2 v2.0.6 // indirect - charm.land/lipgloss/v2 v2.0.3 // indirect codeberg.org/chavacava/garif v0.2.0 // indirect codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect @@ -45,7 +47,6 @@ require ( github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect github.com/ashanbrown/makezero/v2 v2.2.1 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bkielbasa/cyclop v1.2.3 // indirect github.com/blizzy78/varnamelen v0.8.0 // indirect @@ -62,7 +63,6 @@ require ( github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/ckaznocha/intrange v0.3.1 // indirect diff --git a/go.sum b/go.sum index 6b0ecba..3b36f0d 100644 --- a/go.sum +++ b/go.sum @@ -112,6 +112,8 @@ github.com/ashanbrown/makezero/v2 v2.2.1 h1:A7uU8dgB1PA9aelTxHMfHIQ8Qev8AB3JLxJU github.com/ashanbrown/makezero/v2 v2.2.1/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 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= @@ -150,6 +152,8 @@ github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0 github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 9b12de4..ea6e25d 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -7,16 +7,16 @@ import ( "github.com/atotto/clipboard" ) -func tickCmd() tea.Cmd { - return tea.Tick(time.Second, func(t time.Time) tea.Msg { - return tickMsg{t: t} +func toastExpireCmd() tea.Cmd { + return tea.Tick(1500*time.Millisecond, func(time.Time) tea.Msg { + return toastExpiredMsg{} }) } func copyURLCmd(url string) tea.Cmd { return func() tea.Msg { if err := clipboard.WriteAll(url); err != nil { - return clipboardCopiedMsg{msg: "copy failed — no clipboard"} + return clipboardCopiedMsg{msg: "copy failed — check clipboard access"} } return clipboardCopiedMsg{msg: "URL copied"} } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index c97c11f..f89f650 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -4,8 +4,8 @@ import "charm.land/bubbles/v2/key" type keyMap struct { copyURL key.Binding - pause key.Binding help key.Binding + dismiss key.Binding quit key.Binding } @@ -14,14 +14,13 @@ var defaultKeyMap = keyMap{ key.WithKeys("c"), key.WithHelp("c", "copy URL"), ), - pause: key.NewBinding( - key.WithKeys("p"), - key.WithHelp("p", "pause/resume"), - ), help: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "help"), ), + dismiss: key.NewBinding( + key.WithKeys("esc"), + ), quit: key.NewBinding( key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit"), @@ -29,12 +28,11 @@ var defaultKeyMap = keyMap{ } func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.copyURL, k.pause, k.help, k.quit} + return []key.Binding{k.copyURL, k.help, k.quit} } func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.copyURL, k.pause}, - {k.help, k.quit}, + {k.copyURL, k.help, k.quit}, } } diff --git a/internal/tui/model.go b/internal/tui/model.go index 9e4692b..91ae006 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -5,20 +5,19 @@ import ( "strings" "time" - "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" ) +// ringCap bounds the in-memory delivery log; 500 rows balances scrollback depth +// against unbounded memory growth for a long-running session. const ringCap = 500 -// Model is the Bubble Tea model for the hooksctl forward TUI. type Model struct { session SessionInfo deliveries []Delivery vp viewport.Model - help help.Model showHelp bool atBottom bool toastMsg string @@ -30,7 +29,6 @@ type Model struct { cancel context.CancelFunc } -// New returns a Model ready to be run by a Bubble Tea program. func New(session SessionInfo, cancel context.CancelFunc) Model { m := Model{ session: session, @@ -40,16 +38,13 @@ func New(session SessionInfo, cancel context.CancelFunc) Model { cancel: cancel, } m.vp = viewport.New() - m.help = help.New() return m } -// Init satisfies tea.Model. func (m Model) Init() tea.Cmd { - return tea.Batch(tickCmd(), tea.RequestBackgroundColor) + return tea.RequestBackgroundColor } -// appendDelivery appends d to the ring buffer, evicting the oldest entry when at cap. func appendDelivery(m *Model, d Delivery) { if len(m.deliveries) >= ringCap { m.deliveries = m.deliveries[1:] @@ -57,7 +52,6 @@ func appendDelivery(m *Model, d Delivery) { m.deliveries = append(m.deliveries, d) } -// rebuildViewport re-renders all delivery rows into the viewport. func rebuildViewport(m *Model) { var sb strings.Builder for i, d := range m.deliveries { @@ -72,7 +66,6 @@ func rebuildViewport(m *Model) { } } -// Update handles all Bubble Tea messages. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd @@ -83,7 +76,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { headerRows := fixedHeaderRows(m.termH) m.vp.SetWidth(m.termW) m.vp.SetHeight(viewportHeight(m.termH, headerRows)) - m.help.SetWidth(m.termW) rebuildViewport(&m) case tea.BackgroundColorMsg: @@ -106,11 +98,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } rebuildViewport(&m) + case QuitMsg: + if m.cancel != nil { + m.cancel() + } + return m, tea.Quit + case SessionStateMsg: m.session = msg.Info - case tickMsg: - cmds = append(cmds, tickCmd()) + case toastExpiredMsg: if !m.toastExpiry.IsZero() && time.Now().After(m.toastExpiry) { m.toastMsg = "" m.toastExpiry = time.Time{} @@ -119,6 +116,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case clipboardCopiedMsg: m.toastMsg = msg.msg m.toastExpiry = time.Now().Add(1500 * time.Millisecond) + cmds = append(cmds, toastExpireCmd()) case tea.KeyPressMsg: switch { @@ -129,12 +127,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case key.Matches(msg, m.keys.copyURL): cmds = append(cmds, copyURLCmd(m.session.ForwardURL)) - case key.Matches(msg, m.keys.pause): - if m.session.State == StatePaused { - m.session.State = StateOnline - } else { - m.session.State = StatePaused - } + case key.Matches(msg, m.keys.dismiss): + m.showHelp = false case key.Matches(msg, m.keys.help): m.showHelp = !m.showHelp default: diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 288e784..aee1614 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -5,10 +5,8 @@ import lipgloss "charm.land/lipgloss/v2" type tuiStyles struct { title lipgloss.Style dim lipgloss.Style - label lipgloss.Style statusOnline lipgloss.Style statusReconnecting lipgloss.Style - statusPaused lipgloss.Style statusOffline lipgloss.Style forwardURL lipgloss.Style targetURL lipgloss.Style @@ -34,10 +32,8 @@ func newStyles(isDark bool) tuiStyles { return tuiStyles{ title: lipgloss.NewStyle().Foreground(blue).Bold(true), dim: lipgloss.NewStyle().Foreground(dim), - label: lipgloss.NewStyle().Foreground(dim), statusOnline: lipgloss.NewStyle().Foreground(green).Bold(true), statusReconnecting: lipgloss.NewStyle().Foreground(amber).Bold(true), - statusPaused: lipgloss.NewStyle().Foreground(amber).Bold(true), statusOffline: lipgloss.NewStyle().Foreground(dim).Bold(true), forwardURL: lipgloss.NewStyle().Foreground(blue), targetURL: lipgloss.NewStyle().Foreground(dim), diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 5301765..9b9ad9e 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strings" "testing" "time" @@ -30,6 +31,70 @@ func TestViewportHeight(t *testing.T) { } } +// --- fixedHeaderRows --- + +func TestFixedHeaderRows(t *testing.T) { + // >= 24: title(1) + identity(4) + divider(1) + deliveries-header(1) + divider(1) + footer(1) + if got := fixedHeaderRows(40); got != 9 { + t.Errorf("fixedHeaderRows(40) = %d; want 9", got) + } + if got := fixedHeaderRows(24); got != 9 { + t.Errorf("fixedHeaderRows(24) = %d; want 9", got) + } + // < 24: title(1) + identity(2) + divider(1) + deliveries-header(1) + divider(1) + footer(1) + if got := fixedHeaderRows(23); got != 7 { + t.Errorf("fixedHeaderRows(23) = %d; want 7", got) + } + if got := fixedHeaderRows(10); got != 7 { + t.Errorf("fixedHeaderRows(10) = %d; want 7", got) + } +} + +// --- truncate --- + +func TestTruncate(t *testing.T) { + tests := []struct { + s string + max int + want string + }{ + {"hello", 10, "hello"}, + {"hello", 5, "hello"}, + {"hello", 4, "hel…"}, + {"hello", 2, "h…"}, + {"hello", 1, "…"}, + {"hello", 0, "…"}, + {"", 5, ""}, + } + for _, tc := range tests { + got := truncate(tc.s, tc.max) + if got != tc.want { + t.Errorf("truncate(%q, %d) = %q; want %q", tc.s, tc.max, got, tc.want) + } + } +} + +// --- formatUptime --- + +func TestFormatUptime(t *testing.T) { + tests := []struct { + d time.Duration + want string + }{ + {0, "0m00s"}, + {30 * time.Second, "0m30s"}, + {90 * time.Second, "1m30s"}, + {time.Hour + 2*time.Minute + 3*time.Second, "1h02m03s"}, + {-1 * time.Second, "0m00s"}, // negative clamped to zero + } + for _, tc := range tests { + got := formatUptime(tc.d) + if got != tc.want { + t.Errorf("formatUptime(%v) = %q; want %q", tc.d, got, tc.want) + } + } +} + // --- renderDeliveryRow --- func TestRenderDeliveryRow_StatusColors(t *testing.T) { @@ -131,7 +196,7 @@ func TestAppendDelivery_RingBufferEviction(t *testing.T) { } } -// --- Update: DeliveryReceivedMsg --- +// --- helpers --- func newTestModel() Model { m := New(SessionInfo{State: StateOnline, UptimeStart: time.Now()}, nil) @@ -140,6 +205,8 @@ func newTestModel() Model { return m } +// --- Update: DeliveryReceivedMsg --- + func TestUpdate_DeliveryReceived(t *testing.T) { m := newTestModel() @@ -176,29 +243,127 @@ func TestUpdate_DeliveryCompleted(t *testing.T) { } } -// --- Update: atBottom behavior --- +func TestUpdate_DeliveryCompletedUnknownID(t *testing.T) { + m := newTestModel() + d := Delivery{ID: "d1", RecvAt: time.Now(), InFlight: true} + next, _ := m.Update(DeliveryReceivedMsg{Delivery: d}) + m = next.(Model) -func TestUpdate_ScrollUpDisablesAutoScroll(t *testing.T) { + // Completing with an unknown ID is a no-op — delivery stays in-flight. + next, _ = m.Update(DeliveryCompletedMsg{ID: "ghost", Status: 200}) + m = next.(Model) + + if len(m.deliveries) != 1 { + t.Fatalf("expected 1 delivery, got %d", len(m.deliveries)) + } + if !m.deliveries[0].InFlight { + t.Error("delivery should still be in-flight — unknown ID should be a no-op") + } +} + +// --- Update: SessionStateMsg --- + +func TestUpdate_SessionStateMsg(t *testing.T) { m := newTestModel() - // Seed some deliveries - for i := range 10 { - d := Delivery{ID: string(rune('a' + i)), RecvAt: time.Now(), Method: "POST", Path: "/r", Source: "render"} - next, _ := m.Update(DeliveryReceivedMsg{Delivery: d}) - m = next.(Model) + + info := SessionInfo{ + State: StateReconnecting, + ReconnectCount: 2, + UptimeStart: time.Now(), + ForwardURL: "https://example.com", } - if !m.atBottom { - t.Fatal("model should start at bottom") + next, _ := m.Update(SessionStateMsg{Info: info}) + nm := next.(Model) + + if nm.session.State != StateReconnecting { + t.Errorf("expected StateReconnecting, got %v", nm.session.State) + } + if nm.session.ReconnectCount != 2 { + t.Errorf("expected ReconnectCount 2, got %d", nm.session.ReconnectCount) + } +} + +// --- Update: toast lifecycle --- + +func TestUpdate_ToastLifecycle(t *testing.T) { + m := newTestModel() + + // clipboardCopiedMsg sets toastMsg and schedules expiry. + next, cmd := m.Update(clipboardCopiedMsg{msg: "URL copied"}) + m = next.(Model) + if m.toastMsg != "URL copied" { + t.Errorf("expected toastMsg 'URL copied', got %q", m.toastMsg) + } + if cmd == nil { + t.Fatal("expected toastExpireCmd from clipboardCopiedMsg") + } + + // toastExpiredMsg with an already-expired expiry clears the toast. + m.toastExpiry = time.Now().Add(-time.Second) + next, _ = m.Update(toastExpiredMsg{}) + m = next.(Model) + if m.toastMsg != "" { + t.Errorf("toast should be cleared after expiry; got %q", m.toastMsg) + } +} + +func TestUpdate_ToastNotClearedBeforeExpiry(t *testing.T) { + m := newTestModel() + + next, _ := m.Update(clipboardCopiedMsg{msg: "hello"}) + m = next.(Model) + // Move expiry into the future so the message hasn't expired. + m.toastExpiry = time.Now().Add(10 * time.Second) + + next, _ = m.Update(toastExpiredMsg{}) + m = next.(Model) + if m.toastMsg != "hello" { + t.Errorf("toast should not be cleared before expiry; got %q", m.toastMsg) } +} - // Send a scroll-up key; viewport will handle it but atBottom should update - next, _ := m.Update(tea.KeyPressMsg{Code: 'k'}) +// --- Update: help toggle --- + +func TestUpdate_HelpKeyTogglesShowHelp(t *testing.T) { + m := newTestModel() + if m.showHelp { + t.Fatal("showHelp should start false") + } + + next, _ := m.Update(tea.KeyPressMsg{Code: '?'}) m = next.(Model) - // atBottom is updated to m.vp.AtBottom() after each unhandled key - // In a zero-size viewport AtBottom() may still be true; just confirm no panic - _ = m.atBottom + if !m.showHelp { + t.Error("showHelp should be true after pressing ?") + } + + next, _ = m.Update(tea.KeyPressMsg{Code: '?'}) + m = next.(Model) + if m.showHelp { + t.Error("showHelp should be false after pressing ? again") + } +} + +// --- Update: QuitMsg --- + +func TestUpdate_QuitMsgCallsCancelAndQuits(t *testing.T) { + cancelled := false + cancel := func() { cancelled = true } + m := New(SessionInfo{State: StateOnline, UptimeStart: time.Now()}, cancel) + + _, cmd := m.Update(QuitMsg{}) + if cmd == nil { + t.Fatal("expected a command from QuitMsg") + } + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } + if !cancelled { + t.Error("cancel should have been called on QuitMsg") + } } -// --- Update: quit --- +// --- Update: quit key --- func TestUpdate_QuitProducesQuitCmd(t *testing.T) { cancelled := false @@ -236,3 +401,159 @@ func TestUpdate_CtrlCProducesQuitCmd(t *testing.T) { t.Errorf("expected QuitMsg from ctrl+c, got %T", msg) } } + +// --- Update: WindowSizeMsg --- + +func TestUpdate_WindowSizeSetsTermDimensions(t *testing.T) { + m := New(SessionInfo{State: StateOnline, UptimeStart: time.Now()}, nil) + + next, _ := m.Update(tea.WindowSizeMsg{Width: 150, Height: 50}) + nm := next.(Model) + + if nm.termW != 150 { + t.Errorf("expected termW 150, got %d", nm.termW) + } + if nm.termH != 50 { + t.Errorf("expected termH 50, got %d", nm.termH) + } +} + +// --- Update: atBottom behavior --- + +func TestUpdate_ScrollUpDisablesAutoScroll(t *testing.T) { + m := New(SessionInfo{State: StateOnline, UptimeStart: time.Now()}, nil) + + // Size the model so the viewport has real dimensions (height=20 → viewport=13 rows). + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 20}) + m = next.(Model) + + // Add 50 deliveries — far more than the 13-row viewport, so content overflows. + for i := range 50 { + d := Delivery{ + ID: fmt.Sprintf("d%d", i), + RecvAt: time.Now(), + Method: "POST", + Path: "/r", + Source: "render", + } + next, _ := m.Update(DeliveryReceivedMsg{Delivery: d}) + m = next.(Model) + } + if !m.atBottom { + t.Fatal("model should be at bottom after adding deliveries with atBottom=true") + } + + // Scroll up. The model forwards unhandled keys to the viewport; 'k' is the + // vim-style line-up binding in charm.land/bubbles/v2/viewport. + next, _ = m.Update(tea.KeyPressMsg{Code: 'k'}) + m = next.(Model) + + if m.atBottom { + t.Error("atBottom should be false after scrolling up") + } +} + +// --- Update: copy URL key --- + +func TestUpdate_CopyURLKeyReturnsCmd(t *testing.T) { + m := newTestModel() + m.session.ForwardURL = "https://hooks.example.com/subscribe/render" + + _, cmd := m.Update(tea.KeyPressMsg{Code: 'c'}) + if cmd == nil { + t.Fatal("expected a command from copy URL key") + } +} + +// --- Update: esc key --- + +func TestUpdate_EscDismissesHelp(t *testing.T) { + m := newTestModel() + m.showHelp = true + + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + m = next.(Model) + if m.showHelp { + t.Error("esc should close the help overlay") + } +} + +func TestUpdate_EscDoesNotOpenHelp(t *testing.T) { + m := newTestModel() + if m.showHelp { + t.Fatal("showHelp should start false") + } + + next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + m = next.(Model) + if m.showHelp { + t.Error("esc should not open the help overlay when it is already closed") + } +} + +// --- Update: atBottom when scrolled up --- + +func TestUpdate_ScrollUpDoesNotJumpOnNewDelivery(t *testing.T) { + m := New(SessionInfo{State: StateOnline, UptimeStart: time.Now()}, nil) + next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 20}) + m = next.(Model) + + // Fill viewport past capacity so scrolling is possible. + for i := range 50 { + d := Delivery{ID: fmt.Sprintf("d%d", i), RecvAt: time.Now(), Method: "POST", Path: "/r", Source: "render"} + next, _ := m.Update(DeliveryReceivedMsg{Delivery: d}) + m = next.(Model) + } + + // Scroll up so atBottom becomes false. + next, _ = m.Update(tea.KeyPressMsg{Code: 'k'}) + m = next.(Model) + if m.atBottom { + t.Fatal("expected atBottom=false after scrolling up") + } + + // A new delivery must not hijack the scroll position. + d := Delivery{ID: "new", RecvAt: time.Now(), Method: "POST", Path: "/r", Source: "render"} + next, _ = m.Update(DeliveryReceivedMsg{Delivery: d}) + m = next.(Model) + if m.atBottom { + t.Error("atBottom should remain false when a delivery arrives while scrolled up") + } +} + +// --- sessionPill --- + +func TestSessionPill_States(t *testing.T) { + // Online + m := newTestModel() + m.session.State = StateOnline + got := sessionPill(m) + if !strings.Contains(got, "online") { + t.Errorf("online pill should contain 'online'; got %q", got) + } + + // Reconnecting without count — no parenthetical + m.session.State = StateReconnecting + m.session.ReconnectCount = 0 + got = sessionPill(m) + if !strings.Contains(got, "reconnecting") { + t.Errorf("reconnecting pill should contain 'reconnecting'; got %q", got) + } + if strings.Contains(got, "×") { + t.Errorf("reconnecting pill with count=0 should not contain ×; got %q", got) + } + + // Reconnecting with count + m.session.ReconnectCount = 3 + got = sessionPill(m) + if !strings.Contains(got, "(×3)") { + t.Errorf("reconnecting pill with count=3 should contain (×3); got %q", got) + } + + // Offline (default branch) + m.session.State = StateOffline + got = sessionPill(m) + if !strings.Contains(got, "offline") { + t.Errorf("offline pill should contain 'offline'; got %q", got) + } +} diff --git a/internal/tui/types.go b/internal/tui/types.go index e1645ea..fae7036 100644 --- a/internal/tui/types.go +++ b/internal/tui/types.go @@ -6,10 +6,9 @@ import "time" type SessionState int const ( - StateOnline SessionState = iota - StateReconnecting SessionState = iota - StatePaused SessionState = iota - StateOffline SessionState = iota + StateOnline SessionState = iota + StateReconnecting + StateOffline ) // SessionInfo holds the display data shown in the session header. @@ -53,6 +52,10 @@ type DeliveryCompletedMsg struct { // SessionStateMsg is sent when the session connection state changes. type SessionStateMsg struct{ Info SessionInfo } -type tickMsg struct{ t time.Time } +// QuitMsg tells the model to quit the program. Send it from outside the TUI +// (e.g. the forward goroutine) when the program must exit programmatically. +type QuitMsg struct{} + +type toastExpiredMsg struct{} type clipboardCopiedMsg struct{ msg string } diff --git a/internal/tui/view.go b/internal/tui/view.go index 3eae5b3..56b23ef 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -9,8 +9,7 @@ import ( lipgloss "charm.land/lipgloss/v2" ) -// Version is the hooksctl version string shown in the TUI title and help overlay. -// Override at build time: -ldflags "-X charm.land/bubbletea/v2.Version=v1.2.3" +// Override at build time: -ldflags "-X github.com/onebusaway/hooks/internal/tui.Version=v1.2.3" var Version = "dev" // View satisfies tea.Model and returns the full-screen TUI view. @@ -82,12 +81,15 @@ func renderIdentity(m Model) string { return statusLine + "\n" + route } - email := m.st.dim.Render("account ") + m.session.Email scopeStr := strings.Join(m.session.Scopes, ", ") token := m.st.dim.Render("token ") + m.st.tokenHighlight.Render(m.session.TokenPrefix+"…"+m.session.TokenSuffix) + m.st.dim.Render(" "+scopeStr) + if m.session.Email == "" { + return statusLine + "\n" + route + "\n" + token + } + email := m.st.dim.Render("account ") + m.session.Email return statusLine + "\n" + email + "\n" + route + "\n" + token } @@ -101,8 +103,6 @@ func sessionPill(m Model) string { rc = fmt.Sprintf(" (×%d)", m.session.ReconnectCount) } return m.st.statusReconnecting.Render("● reconnecting" + rc) - case StatePaused: - return m.st.statusPaused.Render("● paused") default: return m.st.statusOffline.Render("● offline") } @@ -153,6 +153,7 @@ func renderDeliveryRow(d Delivery, termW int, st tuiStyles) string { rightStr := strings.Join(rightParts, " ") // fixed prefix width (without ANSI): ts(12) + sp(1) + method(6) + sp(1) + source(18) + sp(1) + status + // Keep these widths in sync with the %-6s and %-18s format strings above. // status visible width: 4 for code, 11 for "⇡ in flight" statusVisW := 4 if d.InFlight { @@ -186,20 +187,19 @@ func renderKeybindBar(m Model) string { return m.st.keybindChip.Render(" "+k+" ") + " " + label } parts := []string{ - chip("c", "copy URL"), - chip("p", "pause"), - chip("?", "help"), - chip("q", "quit"), + chip(m.keys.copyURL.Help().Key, m.keys.copyURL.Help().Desc), + chip(m.keys.help.Help().Key, m.keys.help.Help().Desc), + chip(m.keys.quit.Help().Key, m.keys.quit.Help().Desc), } return strings.Join(parts, " ") } +// renderHelpOverlay renders the help box. Key strings must stay in sync with defaultKeyMap in keys.go. func renderHelpOverlay(m Model) string { var sb strings.Builder sb.WriteString("┌── Help ──────────────────────────────┐\n") sb.WriteString("│ │\n") sb.WriteString("│ c copy forwarding URL │\n") - sb.WriteString("│ p pause / resume │\n") sb.WriteString("│ ?/esc toggle help │\n") sb.WriteString("│ q/^C quit │\n") sb.WriteString("│ ↑↓ scroll │\n") From 5e4aadd318556866b8da6d37157d0065b712a6cc Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Tue, 12 May 2026 21:03:24 -0700 Subject: [PATCH 6/7] fix(tui): tick uptime counter every second Init only returned tea.RequestBackgroundColor, so the view never re-rendered and the uptime display stayed frozen at 0m00s. --- internal/tui/commands.go | 6 ++++++ internal/tui/model.go | 5 ++++- internal/tui/types.go | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index ea6e25d..10bfe55 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -13,6 +13,12 @@ func toastExpireCmd() tea.Cmd { }) } +func uptimeTickCmd() tea.Cmd { + return tea.Tick(time.Second, func(time.Time) tea.Msg { + return uptimeTickMsg{} + }) +} + func copyURLCmd(url string) tea.Cmd { return func() tea.Msg { if err := clipboard.WriteAll(url); err != nil { diff --git a/internal/tui/model.go b/internal/tui/model.go index 91ae006..7f2eb03 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -42,7 +42,7 @@ func New(session SessionInfo, cancel context.CancelFunc) Model { } func (m Model) Init() tea.Cmd { - return tea.RequestBackgroundColor + return tea.Batch(tea.RequestBackgroundColor, uptimeTickCmd()) } func appendDelivery(m *Model, d Delivery) { @@ -107,6 +107,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case SessionStateMsg: m.session = msg.Info + case uptimeTickMsg: + cmds = append(cmds, uptimeTickCmd()) + case toastExpiredMsg: if !m.toastExpiry.IsZero() && time.Now().After(m.toastExpiry) { m.toastMsg = "" diff --git a/internal/tui/types.go b/internal/tui/types.go index fae7036..2bc5ec4 100644 --- a/internal/tui/types.go +++ b/internal/tui/types.go @@ -59,3 +59,5 @@ type QuitMsg struct{} type toastExpiredMsg struct{} type clipboardCopiedMsg struct{ msg string } + +type uptimeTickMsg struct{} From acb67b1bdd625854c046d3b646699b69cbd4d619 Mon Sep 17 00:00:00 2001 From: Aaron Brethorst Date: Wed, 13 May 2026 21:19:49 -0700 Subject: [PATCH 7/7] fix(tui): address post-review bugs and test gaps - Fix terminal corruption: remove errant RestoreTerminal defer and suppress stderr writes while alt-screen TUI is active - Fix Ctrl-C exit code: return 0 when prog.Run errors due to ctx cancel (tea.WithContext causes ErrProgramKilled on signal, not a real error) - Fix phantom in-flight rows on reconnect: appendDelivery deduplicates by ID, updating existing rows in-place instead of appending duplicates - Fix stuck in-flight rows: forwardOneTUI sends intermediate DeliveryCompletedMsg per retry attempt so the TUI reflects progress - Fix stale HTTP status in final completion: reset finalStatus each iteration so transport errors don't carry over a prior status code - Fix phantom viewport row: fixedHeaderRows(termH, hasEmail) accounts for the 3-row identity section when email is absent (was always 4) - Surface malformed SSE events as TUI rows with Suffix="malformed" instead of silently dropping them - Add tea.WithContext(ctx) to tie program lifetime to parent context - Log saveCursor write failures in non-TUI mode; suppress in TUI mode to avoid corrupting the alt-screen display - Include actual clipboard error in copyURLCmd failure toast - Add suffix constants (deliverySuffixMalformed, etc.) and use http.MethodPost instead of "POST" string literals - Add tests: forwardOneTUI malformed payload, non-2xx retry, transport-error retry, appendDelivery dedup+eviction interaction - Fix .gitignore: anchor hooksctl rule to root (was matching cmd/hooksctl/) --- .gitignore | 2 +- cmd/hooksctl/forward.go | 57 ++++++-- cmd/hooksctl/forward_unit_test.go | 218 ++++++++++++++++++++++++++++++ internal/tui/commands.go | 2 +- internal/tui/model.go | 8 +- internal/tui/tui_test.go | 63 +++++++-- internal/tui/view.go | 13 +- 7 files changed, 336 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 3782f09..26e2929 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ hooks.yaml .env .claude/scheduled_tasks.lock .playwright-mcp/ -hooksctl \ No newline at end of file +/hooksctl \ No newline at end of file diff --git a/cmd/hooksctl/forward.go b/cmd/hooksctl/forward.go index 8afbe37..fa37c20 100644 --- a/cmd/hooksctl/forward.go +++ b/cmd/hooksctl/forward.go @@ -30,6 +30,14 @@ import ( // own context from os signals. var forwardTestCtx context.Context +const ( + deliverySuffixMalformed = "malformed" + deliverySuffixTransportErr = "transport err" + deliverySuffixRetrying = "retrying" + deliverySuffixCancelled = "cancelled" + deliverySuffixErr = "err" +) + // errSkipEvent is returned when an event payload is permanently malformed. // The caller advances the cursor past the broken event rather than reconnecting. var errSkipEvent = errors.New("skip event") @@ -160,8 +168,7 @@ func runWithTUI(ctx context.Context, cancel context.CancelFunc, g globals, sourc } model := tui.New(baseSession, cancel) - prog := tea.NewProgram(model) - defer func() { _ = prog.RestoreTerminal() }() + prog := tea.NewProgram(model, tea.WithContext(ctx)) errCh := make(chan error, 1) go func() { @@ -196,7 +203,6 @@ func runWithTUI(ctx context.Context, cancel context.CancelFunc, g globals, sourc info.ReconnectCount = reconnectCount prog.Send(tui.SessionStateMsg{Info: info}) - fmt.Fprintf(os.Stderr, "forward: %v; reconnecting\n", err) d := backoff() select { case <-ctx.Done(): @@ -207,6 +213,10 @@ func runWithTUI(ctx context.Context, cancel context.CancelFunc, g globals, sourc }() if _, err := prog.Run(); err != nil { + if ctx.Err() != nil { + return 0 + } + cancel() fmt.Fprintln(os.Stderr, "forward:", err) return 1 } @@ -228,24 +238,38 @@ func streamFromCursorTUI(ctx context.Context, prog *tea.Program, g globals, bear func forwardOneTUI(ctx context.Context, prog *tea.Program, cli *http.Client, to string, msg map[string]string, source string, exitOnError bool) error { p, err := parseEventPayload(msg) if err != nil { + prog.Send(tui.DeliveryReceivedMsg{Delivery: tui.Delivery{ + ID: msg["id"], + RecvAt: time.Now(), + Method: http.MethodPost, + Path: "/" + source, + Source: source, + Suffix: deliverySuffixMalformed, + }}) return err } - prog.Send(tui.DeliveryReceivedMsg{Delivery: tui.Delivery{ + recv := tui.Delivery{ ID: p.DeliveryID, RecvAt: time.Now(), - Method: "POST", + Method: http.MethodPost, Path: "/" + source, Source: source, InFlight: true, SizeBytes: int64(len(p.Body)), - }}) + } + prog.Send(tui.DeliveryReceivedMsg{Delivery: recv}) start := time.Now() var finalStatus int var forwardErr error for attempt := 0; ; attempt++ { + if attempt > 0 { + prog.Send(tui.DeliveryReceivedMsg{Delivery: recv}) + } + finalStatus = 0 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, to, bytes.NewReader(p.Body)) if err != nil { forwardErr = err @@ -271,6 +295,11 @@ func forwardOneTUI(ctx context.Context, prog *tea.Program, cli *http.Client, to forwardErr = fmt.Errorf("transport: %w", err) break } + prog.Send(tui.DeliveryCompletedMsg{ + ID: p.DeliveryID, + DurationMS: time.Since(start).Milliseconds(), + Suffix: deliverySuffixTransportErr, + }) if !sleepWithCtx(ctx, attemptBackoff(attempt)) { forwardErr = ctx.Err() break @@ -286,6 +315,12 @@ func forwardOneTUI(ctx context.Context, prog *tea.Program, cli *http.Client, to forwardErr = fmt.Errorf("target returned %d", resp.StatusCode) break } + prog.Send(tui.DeliveryCompletedMsg{ + ID: p.DeliveryID, + Status: resp.StatusCode, + DurationMS: time.Since(start).Milliseconds(), + Suffix: deliverySuffixRetrying, + }) if !sleepWithCtx(ctx, attemptBackoff(attempt)) { forwardErr = ctx.Err() break @@ -295,9 +330,9 @@ func forwardOneTUI(ctx context.Context, prog *tea.Program, cli *http.Client, to suffix := "" if forwardErr != nil { if ctx.Err() != nil { - suffix = "cancelled" + suffix = deliverySuffixCancelled } else { - suffix = "err" + suffix = deliverySuffixErr } } @@ -506,7 +541,11 @@ func loadCursor(path string) int64 { } func saveCursor(path string, seq int64) { - _ = os.WriteFile(path, []byte(strconv.FormatInt(seq, 10)+"\n"), 0o600) + if err := os.WriteFile(path, []byte(strconv.FormatInt(seq, 10)+"\n"), 0o600); err != nil { + if !xterm.IsTerminal(os.Stdout.Fd()) { + fmt.Fprintf(os.Stderr, "forward: save cursor: %v\n", err) + } + } } // ephemeralListener is the in-memory record of a `kind='listener'`, diff --git a/cmd/hooksctl/forward_unit_test.go b/cmd/hooksctl/forward_unit_test.go index f07c6dd..deb9f73 100644 --- a/cmd/hooksctl/forward_unit_test.go +++ b/cmd/hooksctl/forward_unit_test.go @@ -9,7 +9,13 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strings" + "sync/atomic" "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/onebusaway/hooks/internal/tui" ) // --- parseEventPayload --- @@ -133,3 +139,215 @@ func TestStreamFromCursorWith_SkipsErrSkipEvent(t *testing.T) { t.Errorf("cursor = %d; want 2 (advanced past both events)", cursor) } } + +// --- forwardOneTUI --- + +// tuiCapture accumulates TUI messages sent by forwardOneTUI via a headless +// tea.Program (WithoutRenderer + WithInput(nil)). +type tuiCapture struct { + received []tui.DeliveryReceivedMsg + completed []tui.DeliveryCompletedMsg +} + +type captureProgModel struct{ c *tuiCapture } + +func (m captureProgModel) Init() tea.Cmd { return nil } +func (m captureProgModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tui.QuitMsg: + return m, tea.Quit + case tui.DeliveryReceivedMsg: + m.c.received = append(m.c.received, msg) + case tui.DeliveryCompletedMsg: + m.c.completed = append(m.c.completed, msg) + } + return m, nil +} +func (m captureProgModel) View() tea.View { return tea.NewView("") } + +func newCaptureProg(c *tuiCapture) *tea.Program { + return tea.NewProgram(captureProgModel{c: c}, + tea.WithoutRenderer(), + tea.WithInput(nil), + ) +} + +// runCaptureProg starts the program in a goroutine and returns a channel that +// closes when prog.Run() returns. +func runCaptureProg(prog *tea.Program) <-chan struct{} { + done := make(chan struct{}) + go func() { + defer close(done) + _, _ = prog.Run() + }() + return done +} + +func TestForwardOneTUI_MalformedPayload(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("target must not be reached for a malformed event") + })) + defer target.Close() + + capt := &tuiCapture{} + prog := newCaptureProg(capt) + done := runCaptureProg(prog) + + msg := map[string]string{ + "id": "42", + "event": "render", + "data": "not-valid-json", + } + err := forwardOneTUI(ctx, prog, &http.Client{Timeout: time.Second}, target.URL, msg, "render", false) + prog.Send(tui.QuitMsg{}) + <-done + + if !errors.Is(err, errSkipEvent) { + t.Fatalf("want errSkipEvent, got %v", err) + } + if len(capt.received) != 1 { + t.Fatalf("want 1 DeliveryReceivedMsg, got %d", len(capt.received)) + } + if capt.received[0].Delivery.Suffix != deliverySuffixMalformed { + t.Errorf("want Suffix %q, got %q", deliverySuffixMalformed, capt.received[0].Delivery.Suffix) + } + if !strings.HasSuffix(capt.received[0].Delivery.Path, "/render") { + t.Errorf("want Path ending in /render, got %q", capt.received[0].Delivery.Path) + } + if len(capt.completed) != 0 { + t.Errorf("want 0 DeliveryCompletedMsg for malformed, got %d", len(capt.completed)) + } +} + +func TestForwardOneTUI_RetryOnNonSuccess(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var attempts atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if attempts.Add(1) == 1 { + w.WriteHeader(http.StatusServiceUnavailable) + } else { + w.WriteHeader(http.StatusOK) + } + })) + defer target.Close() + + body, _ := json.Marshal(map[string]any{ + "delivery_id": "d1", + "headers": map[string]string{}, + "body": base64.StdEncoding.EncodeToString([]byte(`{}`)), + }) + msg := map[string]string{ + "id": "1", + "event": "render", + "data": string(body), + } + + capt := &tuiCapture{} + prog := newCaptureProg(capt) + done := runCaptureProg(prog) + + err := forwardOneTUI(ctx, prog, &http.Client{Timeout: 5 * time.Second}, target.URL, msg, "render", false) + prog.Send(tui.QuitMsg{}) + <-done + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Must have at least one DeliveryReceivedMsg (initial in-flight). + if len(capt.received) < 1 { + t.Fatalf("want at least 1 DeliveryReceivedMsg, got %d", len(capt.received)) + } + + // Must have an intermediate "retrying" completion for the 503. + hasRetrying := false + for _, c := range capt.completed { + if c.Suffix == deliverySuffixRetrying && c.Status == http.StatusServiceUnavailable { + hasRetrying = true + } + } + if !hasRetrying { + t.Errorf("want intermediate DeliveryCompletedMsg with Suffix=%q and Status=503", deliverySuffixRetrying) + } + + // Final completion must be 200 with no error suffix. + if len(capt.completed) == 0 { + t.Fatal("want at least 1 DeliveryCompletedMsg") + } + final := capt.completed[len(capt.completed)-1] + if final.Status != http.StatusOK { + t.Errorf("want final status 200, got %d", final.Status) + } + if final.Suffix != "" { + t.Errorf("want final suffix '', got %q", final.Suffix) + } +} + +func TestForwardOneTUI_RetryOnTransportError(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // First request: hijack the connection and close it to simulate a transport error. + // Subsequent requests: respond with 200. + var attempts atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if attempts.Add(1) == 1 { + hj, ok := w.(http.Hijacker) + if !ok { + t.Error("server does not support hijacking") + return + } + conn, _, _ := hj.Hijack() + _ = conn.Close() + return + } + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + body, _ := json.Marshal(map[string]any{ + "delivery_id": "d1", + "headers": map[string]string{}, + "body": base64.StdEncoding.EncodeToString([]byte(`{}`)), + }) + msg := map[string]string{ + "id": "1", + "event": "render", + "data": string(body), + } + + capt := &tuiCapture{} + prog := newCaptureProg(capt) + done := runCaptureProg(prog) + + err := forwardOneTUI(ctx, prog, &http.Client{Timeout: 5 * time.Second}, target.URL, msg, "render", false) + prog.Send(tui.QuitMsg{}) + <-done + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + hasTransportErr := false + for _, c := range capt.completed { + if c.Suffix == deliverySuffixTransportErr { + hasTransportErr = true + } + } + if !hasTransportErr { + t.Errorf("want intermediate DeliveryCompletedMsg with Suffix=%q", deliverySuffixTransportErr) + } + + final := capt.completed[len(capt.completed)-1] + if final.Status != http.StatusOK { + t.Errorf("want final status 200, got %d", final.Status) + } + if final.Suffix != "" { + t.Errorf("want final suffix '', got %q", final.Suffix) + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 10bfe55..3b21c53 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -22,7 +22,7 @@ func uptimeTickCmd() tea.Cmd { func copyURLCmd(url string) tea.Cmd { return func() tea.Msg { if err := clipboard.WriteAll(url); err != nil { - return clipboardCopiedMsg{msg: "copy failed — check clipboard access"} + return clipboardCopiedMsg{msg: "copy failed: " + err.Error()} } return clipboardCopiedMsg{msg: "URL copied"} } diff --git a/internal/tui/model.go b/internal/tui/model.go index 7f2eb03..bc0a324 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -46,6 +46,12 @@ func (m Model) Init() tea.Cmd { } func appendDelivery(m *Model, d Delivery) { + for i := range m.deliveries { + if m.deliveries[i].ID == d.ID { + m.deliveries[i] = d + return + } + } if len(m.deliveries) >= ringCap { m.deliveries = m.deliveries[1:] } @@ -73,7 +79,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.termW = msg.Width m.termH = msg.Height - headerRows := fixedHeaderRows(m.termH) + headerRows := fixedHeaderRows(m.termH, m.session.Email != "") m.vp.SetWidth(m.termW) m.vp.SetHeight(viewportHeight(m.termH, headerRows)) rebuildViewport(&m) diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 9b9ad9e..6af45ea 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -34,19 +34,23 @@ func TestViewportHeight(t *testing.T) { // --- fixedHeaderRows --- func TestFixedHeaderRows(t *testing.T) { - // >= 24: title(1) + identity(4) + divider(1) + deliveries-header(1) + divider(1) + footer(1) - if got := fixedHeaderRows(40); got != 9 { - t.Errorf("fixedHeaderRows(40) = %d; want 9", got) + // >= 24 with email: title(1) + identity(4) + divider(1) + deliveries-header(1) + divider(1) + footer(1) + if got := fixedHeaderRows(40, true); got != 9 { + t.Errorf("fixedHeaderRows(40, true) = %d; want 9", got) } - if got := fixedHeaderRows(24); got != 9 { - t.Errorf("fixedHeaderRows(24) = %d; want 9", got) + if got := fixedHeaderRows(24, true); got != 9 { + t.Errorf("fixedHeaderRows(24, true) = %d; want 9", got) + } + // >= 24 without email: title(1) + identity(3) + divider(1) + deliveries-header(1) + divider(1) + footer(1) + if got := fixedHeaderRows(40, false); got != 8 { + t.Errorf("fixedHeaderRows(40, false) = %d; want 8", got) } // < 24: title(1) + identity(2) + divider(1) + deliveries-header(1) + divider(1) + footer(1) - if got := fixedHeaderRows(23); got != 7 { - t.Errorf("fixedHeaderRows(23) = %d; want 7", got) + if got := fixedHeaderRows(23, false); got != 7 { + t.Errorf("fixedHeaderRows(23, false) = %d; want 7", got) } - if got := fixedHeaderRows(10); got != 7 { - t.Errorf("fixedHeaderRows(10) = %d; want 7", got) + if got := fixedHeaderRows(10, true); got != 7 { + t.Errorf("fixedHeaderRows(10, true) = %d; want 7", got) } } @@ -177,15 +181,15 @@ func TestRenderDeliveryRow_ColumnDrop(t *testing.T) { func TestAppendDelivery_RingBufferEviction(t *testing.T) { m := Model{atBottom: true} - // Fill to capacity + // Fill to capacity with unique IDs. for i := range ringCap { - appendDelivery(&m, Delivery{ID: string(rune('a' + i%26)), RecvAt: time.Now()}) + appendDelivery(&m, Delivery{ID: fmt.Sprintf("d%d", i), RecvAt: time.Now()}) } if len(m.deliveries) != ringCap { t.Fatalf("expected %d deliveries, got %d", ringCap, len(m.deliveries)) } - // One more should evict the oldest + // One more should evict the oldest. appendDelivery(&m, Delivery{ID: "new", RecvAt: time.Now()}) if len(m.deliveries) != ringCap { t.Fatalf("after eviction expected %d deliveries, got %d", ringCap, len(m.deliveries)) @@ -196,6 +200,41 @@ func TestAppendDelivery_RingBufferEviction(t *testing.T) { } } +func TestAppendDelivery_DeduplicatesID(t *testing.T) { + m := Model{} + appendDelivery(&m, Delivery{ID: "d1", InFlight: true}) + appendDelivery(&m, Delivery{ID: "d2", InFlight: true}) + + // Re-append d1 with updated fields (simulates reconnect). + appendDelivery(&m, Delivery{ID: "d1", InFlight: true, Status: 200}) + + if len(m.deliveries) != 2 { + t.Fatalf("expected 2 deliveries after dedup, got %d", len(m.deliveries)) + } + if m.deliveries[0].Status != 200 { + t.Errorf("expected d1 status updated to 200, got %d", m.deliveries[0].Status) + } +} + +func TestAppendDelivery_DedupRunsBeforeEviction(t *testing.T) { + m := Model{} + + // Fill to capacity with unique IDs. + for i := range ringCap { + appendDelivery(&m, Delivery{ID: fmt.Sprintf("d%d", i), RecvAt: time.Now()}) + } + + // Re-appending the first entry must update in-place, not evict the oldest. + appendDelivery(&m, Delivery{ID: "d0", RecvAt: time.Now(), Status: 200}) + + if len(m.deliveries) != ringCap { + t.Fatalf("dedup at capacity: want %d deliveries, got %d", ringCap, len(m.deliveries)) + } + if m.deliveries[0].Status != 200 { + t.Errorf("dedup at capacity: want d0 status updated to 200, got %d", m.deliveries[0].Status) + } +} + // --- helpers --- func newTestModel() Model { diff --git a/internal/tui/view.go b/internal/tui/view.go index 56b23ef..8397f94 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -211,10 +211,17 @@ func renderHelpOverlay(m Model) string { } // fixedHeaderRows returns the number of rows consumed by non-viewport layout. -func fixedHeaderRows(termH int) int { - identityRows := 4 - if termH < 24 { +// identityRows is 4 when email is present (status+email+route+token), 3 when +// absent (status+route+token), or 2 for compact terminals (termH < 24). +func fixedHeaderRows(termH int, hasEmail bool) int { + var identityRows int + switch { + case termH < 24: identityRows = 2 + case hasEmail: + identityRows = 4 + default: + identityRows = 3 } // title + identity + divider + deliveries-header + divider + footer return 1 + identityRows + 1 + 1 + 1 + 1