diff --git a/.gitignore b/.gitignore index eb415a2..26e2929 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 diff --git a/cmd/hooksctl/forward.go b/cmd/hooksctl/forward.go index 4685b0f..fa37c20 100644 --- a/cmd/hooksctl/forward.go +++ b/cmd/hooksctl/forward.go @@ -20,13 +20,55 @@ 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 // 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") + +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") @@ -84,6 +126,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 { @@ -93,7 +139,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 { @@ -107,7 +153,214 @@ func cmdForward(g globals, args []string) int { } } -func streamFromCursor(ctx context.Context, g globals, bearer, source string, cursor *int64, cursorPath, to string, cli *http.Client, exitOnError bool) error { +// 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, tea.WithContext(ctx)) + + errCh := make(chan error, 1) + go func() { + reconnectCount := 0 + for { + 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 { + errCh <- err + prog.Send(tui.QuitMsg{}) + return + } + + reconnectCount++ + info = baseSession + info.State = tui.StateReconnecting + info.ReconnectCount = reconnectCount + prog.Send(tui.SessionStateMsg{Info: info}) + + d := backoff() + select { + case <-ctx.Done(): + return + case <-time.After(d): + } + } + }() + + if _, err := prog.Run(); err != nil { + if ctx.Err() != nil { + return 0 + } + cancel() + fmt.Fprintln(os.Stderr, "forward:", err) + return 1 + } + select { + case err := <-errCh: + fmt.Fprintln(os.Stderr, "forward:", err) + return 1 + default: + return 0 + } +} + +func streamFromCursorTUI(ctx context.Context, prog *tea.Program, 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 forwardOneTUI(ctx, prog, cli, to, msg, source, exitOnError) + }) +} + +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 + } + + recv := tui.Delivery{ + ID: p.DeliveryID, + RecvAt: time.Now(), + 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 + 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 + } + prog.Send(tui.DeliveryCompletedMsg{ + ID: p.DeliveryID, + DurationMS: time.Since(start).Milliseconds(), + Suffix: deliverySuffixTransportErr, + }) + 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 + } + 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 + } + } + + suffix := "" + if forwardErr != nil { + if ctx.Err() != nil { + suffix = deliverySuffixCancelled + } else { + suffix = deliverySuffixErr + } + } + + prog.Send(tui.DeliveryCompletedMsg{ + ID: p.DeliveryID, + 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, "" +} + +// 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 { @@ -136,15 +389,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: @@ -156,23 +415,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 } @@ -284,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 new file mode 100644 index 0000000..deb9f73 --- /dev/null +++ b/cmd/hooksctl/forward_unit_test.go @@ -0,0 +1,353 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/onebusaway/hooks/internal/tui" +) + +// --- 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) + } +} + +// --- 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/go.mod b/go.mod index 5dc4935..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,7 +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/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 @@ -57,9 +61,8 @@ 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 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 07ceeee..3b36f0d 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,10 @@ 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/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= @@ -140,10 +148,12 @@ 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/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 new file mode 100644 index 0000000..3b21c53 --- /dev/null +++ b/internal/tui/commands.go @@ -0,0 +1,29 @@ +package tui + +import ( + "time" + + tea "charm.land/bubbletea/v2" + "github.com/atotto/clipboard" +) + +func toastExpireCmd() tea.Cmd { + return tea.Tick(1500*time.Millisecond, func(time.Time) tea.Msg { + return toastExpiredMsg{} + }) +} + +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 { + return clipboardCopiedMsg{msg: "copy failed: " + err.Error()} + } + return clipboardCopiedMsg{msg: "URL copied"} + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..f89f650 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,38 @@ +package tui + +import "charm.land/bubbles/v2/key" + +type keyMap struct { + copyURL key.Binding + help key.Binding + dismiss key.Binding + quit key.Binding +} + +var defaultKeyMap = keyMap{ + copyURL: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "copy URL"), + ), + 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"), + ), +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.copyURL, k.help, k.quit} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.copyURL, k.help, k.quit}, + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..bc0a324 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,154 @@ +package tui + +import ( + "context" + "strings" + "time" + + "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 + +type Model struct { + session SessionInfo + deliveries []Delivery + vp viewport.Model + showHelp bool + atBottom bool + toastMsg string + toastExpiry time.Time + termW int + termH int + keys keyMap + st tuiStyles + cancel context.CancelFunc +} + +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() + return m +} + +func (m Model) Init() tea.Cmd { + return tea.Batch(tea.RequestBackgroundColor, uptimeTickCmd()) +} + +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:] + } + m.deliveries = append(m.deliveries, d) +} + +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() + } +} + +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.session.Email != "") + m.vp.SetWidth(m.termW) + m.vp.SetHeight(viewportHeight(m.termH, headerRows)) + 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 QuitMsg: + if m.cancel != nil { + m.cancel() + } + return m, tea.Quit + + 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 = "" + m.toastExpiry = time.Time{} + } + + case clipboardCopiedMsg: + m.toastMsg = msg.msg + m.toastExpiry = time.Now().Add(1500 * time.Millisecond) + cmds = append(cmds, toastExpireCmd()) + + 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.dismiss): + m.showHelp = false + 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..aee1614 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,63 @@ +package tui + +import lipgloss "charm.land/lipgloss/v2" + +type tuiStyles struct { + title lipgloss.Style + dim lipgloss.Style + statusOnline lipgloss.Style + statusReconnecting 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), + statusOnline: lipgloss.NewStyle().Foreground(green).Bold(true), + statusReconnecting: 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..6af45ea --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,598 @@ +package tui + +import ( + "fmt" + "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) + } + } +} + +// --- fixedHeaderRows --- + +func TestFixedHeaderRows(t *testing.T) { + // >= 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, 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, false); got != 7 { + t.Errorf("fixedHeaderRows(23, false) = %d; want 7", got) + } + if got := fixedHeaderRows(10, true); got != 7 { + t.Errorf("fixedHeaderRows(10, true) = %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) { + 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 with unique IDs. + for i := range ringCap { + 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. + 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) + } +} + +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 { + m := New(SessionInfo{State: StateOnline, UptimeStart: time.Now()}, nil) + m.termW = 120 + m.termH = 40 + return m +} + +// --- Update: DeliveryReceivedMsg --- + +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) + } +} + +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) + + // 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() + + info := SessionInfo{ + State: StateReconnecting, + ReconnectCount: 2, + UptimeStart: time.Now(), + ForwardURL: "https://example.com", + } + 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) + } +} + +// --- 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) + 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 key --- + +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) + } +} + +// --- 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 new file mode 100644 index 0000000..2bc5ec4 --- /dev/null +++ b/internal/tui/types.go @@ -0,0 +1,63 @@ +package tui + +import "time" + +// SessionState describes the connection state of a forward session. +type SessionState int + +const ( + StateOnline SessionState = iota + StateReconnecting + StateOffline +) + +// 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 } + +// 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 } + +type uptimeTickMsg struct{} diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..8397f94 --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,262 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + lipgloss "charm.land/lipgloss/v2" +) + +// 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. +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 + } + + 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 +} + +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) + 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 + // 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 { + 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(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("│ ?/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. +// 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 +} + +// 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/.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..1419b8e --- /dev/null +++ b/openspec/changes/hooksctl-tui/design.md @@ -0,0 +1,105 @@ +## 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. Bubble Tea v2 + Lip Gloss styles defined at package init, not per-render + +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. 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. + +## 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. diff --git a/openspec/changes/hooksctl-tui/proposal.md b/openspec/changes/hooksctl-tui/proposal.md new file mode 100644 index 0000000..7e4c122 --- /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, 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 + +### 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` (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 new file mode 100644 index 0000000..b7cfe4e --- /dev/null +++ b/openspec/changes/hooksctl-tui/specs/forward-tui/spec.md @@ -0,0 +1,149 @@ +## 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; events continue to arrive but the visual state reflects paused + +#### 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 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 + +--- + +### 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, `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** 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** the keybind bar text is replaced by "copy failed — no clipboard" for 1.5 seconds, then the bar is restored + +--- + +### 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: Quit +Pressing `q` or `^C` SHALL cancel the SSE consumer and exit immediately. + +#### Scenario: Quit +- **WHEN** the user presses `q` or `^C` +- **THEN** the SSE consumer context is cancelled and the program exits + +--- + +### 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..e80bafb --- /dev/null +++ b/openspec/changes/hooksctl-tui/tasks.md @@ -0,0 +1,83 @@ +## 1. Dependencies & Module Setup + +- [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 + +- [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 + +- [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 + +- [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 + +- [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 + +- [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 + +- [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 + +- [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 + +- [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 + +- [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) +- [x] 11.4 Remove stub `doc.go` if package has real files; ensure no dead code