Skip to content

feat(tui): add full-screen Bubble Tea TUI to hooksctl forward#10

Merged
aaronbrethorst merged 7 commits into
mainfrom
tui
May 14, 2026
Merged

feat(tui): add full-screen Bubble Tea TUI to hooksctl forward#10
aaronbrethorst merged 7 commits into
mainfrom
tui

Conversation

@aaronbrethorst
Copy link
Copy Markdown
Member

@aaronbrethorst aaronbrethorst commented May 12, 2026

Summary

  • Adds a full-screen Bubble Tea TUI to hooksctl forward when stdout is a TTY: live delivery log with status, latency, size, source, uptime, session state (online / reconnecting / offline), copy-URL keybind, and a help overlay
  • Fixes a cluster of post-review bugs found before merge: phantom in-flight rows on SSE reconnect, deliveries stuck as ⇡ in flight during retries, malformed events silently dropped, wrong exit code on Ctrl-C, stale HTTP status in final completion message, phantom viewport row when email is absent, and terminal corruption from stderr writes into the alt-screen
  • Adds 5 new targeted tests for the TUI forward path (malformed payload, non-2xx retry, transport-error retry, appendDelivery dedup, dedup-at-capacity interaction)

Test plan

  • make test — full suite passes with -race
  • make lint — 0 issues
  • TestForwardOneTUI_MalformedPayload — target not reached; TUI row shows malformed suffix; errSkipEvent returned
  • TestForwardOneTUI_RetryOnNonSuccess — intermediate retrying row shown for 503; final row shows 200
  • TestForwardOneTUI_RetryOnTransportError — hijacked connection produces transport err row; final row shows 200
  • TestAppendDelivery_DeduplicatesID — reconnect re-deliver updates existing row in-place
  • TestAppendDelivery_DedupRunsBeforeEviction — dedup check runs before ring-eviction so re-appending at capacity doesn't lose the oldest unrelated entry
  • TestFixedHeaderRows — 4-row identity with email, 3-row without, 2-row compact

Review notes

  • appendDelivery dedup clobbers completed→in-flight on reconnect: When a cancelled delivery is re-delivered after reconnect, replacing the row with InFlight=true is intentional — the event is being retried. The cursor is only advanced after a successful forwardOneTUI, so a completed-200 row can never be re-delivered.
  • runWithTUI cancel() on error untested: Would require making prog.Run() return a non-context error from a test double; deferred for a future integration harness.
  • copyURLCmd failure message untested: Display-only contract; clipboard.WriteAll can't be easily injected. Low priority.

Proposal, design, spec, and tasks for the full-screen Bubble Tea
status dashboard for `hooksctl forward`.
- 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
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.
- 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
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

📝 Walkthrough

Walkthrough

This PR introduces a full-screen Bubble Tea TUI for hooksctl forward when stdout is a TTY. It adds type definitions, a state-driven model with viewport scrolling, responsive view rendering, event parsing and routing logic, comprehensive tests, and specification documentation.

Changes

Bubble Tea TUI for hooksctl forward

Layer / File(s) Summary
Dependencies and Core Types
.gitignore, go.mod, internal/tui/types.go
Add Bubble Tea, Lipgloss, clipboard dependencies; define SessionState, SessionInfo, Delivery, and Bubble Tea message types (DeliveryReceivedMsg, DeliveryCompletedMsg, SessionStateMsg, QuitMsg).
TUI Model, Styling, and Commands
internal/tui/model.go, internal/tui/styles.go, internal/tui/keys.go, internal/tui/commands.go
Implement the Bubble Tea Model with session tracking, ring-buffered deliveries, viewport management, and full Update handler; define tuiStyles with HTTP status-code-aware colors; wire keyMap bindings for copy/help/quit; add toastExpireCmd and copyURLCmd helpers.
TUI View and Rendering
internal/tui/view.go
Implement Model.View() and render pipeline: title with version, identity/status pill with uptime, delivery log header, scrollable viewport, responsive column dropping by width, keybind bar with timed toast overlay, and help modal; include layout helpers (fixedHeaderRows, viewportHeight), string utilities (truncate, formatUptime).
Forward Command Event Parsing and TUI Routing
cmd/hooksctl/forward.go
Add parseEventPayload to decode SSE events, base64-decode body, and normalize DeliveryID; detect TTY in cmdForward and route to runWithTUI (manage TUI goroutine + session state) or non-TUI path; factor SSE streaming into streamFromCursorWith with error-skipping for malformed payloads.
TUI Integration Testing
internal/tui/tui_test.go
Test layout calculations, string rendering/truncation, delivery row styling with status colors and responsive column dropping, ring-buffer eviction, message handling (delivery received/completed, session state, toast lifecycle, help toggle, quit binding and cancel invocation, window resize, scrolling behavior), and session state pill rendering.
Forward Command Unit Tests
cmd/hooksctl/forward_unit_test.go
Test parseEventPayload JSON/base64 decoding and DeliveryID fallback; test tokenFingerprint edge cases; verify streamFromCursorWith cursor advancement when handler returns errSkipEvent.
Specification and Design Documentation
openspec/changes/hooksctl-tui/*
Define proposal with capability, design constraints/trade-offs/rollback plan, OpenAPI-style requirements spec covering TTY routing, session header, delivery tail ring-buffer, responsive layout, keybind actions, help overlay, and implementation task checklist.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.55% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and accurately summarizes the main change: adding a full-screen Bubble Tea TUI to hooksctl forward. It is concise, specific, and directly reflects the primary objective of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch tui

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
internal/tui/types.go (1)

8-12: 💤 Low value

Consider documenting StateOffline in the spec.

The SessionState enum includes StateOffline (line 11), but the spec (specs/forward-tui/spec.md) only documents scenarios for online, reconnecting, and paused states. If StateOffline is used in practice, it should be documented in the spec for completeness.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/types.go` around lines 8 - 12, SessionState defines an extra
value StateOffline that isn’t described in the spec; update the TUI spec to add
documentation for StateOffline including its semantic meaning, valid transitions
to/from StateOnline and StateReconnecting, when it should be emitted/observed,
and the expected UI behavior (e.g., indicators, user actions or retry logic).
Reference the enum name SessionState and the literal StateOffline in the spec
text so readers can correlate the code and include any examples or sequence
diagrams showing transitions involving StateOffline.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/tui/view.go`:
- Around line 214-220: fixedHeaderRows overestimates identity height because it
always uses 4 rows for tall terminals while renderIdentity emits 3 rows when
Email is empty; change fixedHeaderRows to compute identityRows the same way
renderIdentity does (use 2 rows when termH < 24, otherwise 3 if identity.Email
is empty, 4 if not). Implement this by adding a parameter (e.g., hasEmail bool
or identity struct) to fixedHeaderRows and updating all callers to pass the
identity email presence, then adjust the return calculation (1 + identityRows +
1 + 1 + 1 + 1) accordingly.

In `@openspec/changes/hooksctl-tui/tasks.md`:
- Around line 28-31: The checklist incorrectly references a removed pause
keybinding; remove or update the stale items that mention the `pause` key from
the checklist entries that list `keyMap` bindings and the Update wiring (items
mentioning `p`, `pause`, and pause/resume behavior). Specifically, edit the
checklist so `keyMap` only documents the current bindings (e.g., `copyURL`,
`help`, `quit`), and remove any TODOs about implementing `ShortHelp()`,
`FullHelp()` or `Update()` wiring for a `pause`/`p` binding so the checklist
matches the actual `keyMap`, `ShortHelp`, `FullHelp`, and `Update` behavior.

---

Nitpick comments:
In `@internal/tui/types.go`:
- Around line 8-12: SessionState defines an extra value StateOffline that isn’t
described in the spec; update the TUI spec to add documentation for StateOffline
including its semantic meaning, valid transitions to/from StateOnline and
StateReconnecting, when it should be emitted/observed, and the expected UI
behavior (e.g., indicators, user actions or retry logic). Reference the enum
name SessionState and the literal StateOffline in the spec text so readers can
correlate the code and include any examples or sequence diagrams showing
transitions involving StateOffline.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5850b9dc-9391-402b-8183-e44b26af8be5

📥 Commits

Reviewing files that changed from the base of the PR and between fff195b and 3701678.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (16)
  • .gitignore
  • cmd/hooksctl/forward.go
  • cmd/hooksctl/forward_unit_test.go
  • go.mod
  • internal/tui/commands.go
  • internal/tui/keys.go
  • internal/tui/model.go
  • internal/tui/styles.go
  • internal/tui/tui_test.go
  • internal/tui/types.go
  • internal/tui/view.go
  • openspec/changes/hooksctl-tui/.openspec.yaml
  • openspec/changes/hooksctl-tui/design.md
  • openspec/changes/hooksctl-tui/proposal.md
  • openspec/changes/hooksctl-tui/specs/forward-tui/spec.md
  • openspec/changes/hooksctl-tui/tasks.md

Comment thread internal/tui/view.go Outdated
Comment on lines +28 to +31
- [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`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove stale pause keybinding tasks from the checklist.

These checklist items still describe p pause/resume behavior, but the current TUI implementation and reviewer notes indicate pause keybinding is no longer part of the shipped interaction model. This can mislead future maintenance/testing.

Also applies to: 42-42

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@openspec/changes/hooksctl-tui/tasks.md` around lines 28 - 31, The checklist
incorrectly references a removed pause keybinding; remove or update the stale
items that mention the `pause` key from the checklist entries that list `keyMap`
bindings and the Update wiring (items mentioning `p`, `pause`, and pause/resume
behavior). Specifically, edit the checklist so `keyMap` only documents the
current bindings (e.g., `copyURL`, `help`, `quit`), and remove any TODOs about
implementing `ShortHelp()`, `FullHelp()` or `Update()` wiring for a `pause`/`p`
binding so the checklist matches the actual `keyMap`, `ShortHelp`, `FullHelp`,
and `Update` behavior.

Init only returned tea.RequestBackgroundColor, so the view never re-rendered
and the uptime display stayed frozen at 0m00s.
- 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/)
@sonarqubecloud
Copy link
Copy Markdown

@aaronbrethorst aaronbrethorst merged commit f560895 into main May 14, 2026
7 checks passed
@aaronbrethorst aaronbrethorst deleted the tui branch May 14, 2026 04:26
@aaronbrethorst aaronbrethorst restored the tui branch May 24, 2026 04:53
@aaronbrethorst aaronbrethorst deleted the tui branch May 24, 2026 04:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant