From bcc4430de1ba061be1ac90b95478451914ac0554 Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 12:36:26 -0400 Subject: [PATCH 1/7] docs: add TUI improvements design Header shortcuts bar, n-key session spawning, and full-height centered card layout. --- .../2026-03-18-tui-improvements-design.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 docs/plans/2026-03-18-tui-improvements-design.md diff --git a/docs/plans/2026-03-18-tui-improvements-design.md b/docs/plans/2026-03-18-tui-improvements-design.md new file mode 100644 index 0000000..5eab154 --- /dev/null +++ b/docs/plans/2026-03-18-tui-improvements-design.md @@ -0,0 +1,42 @@ +# TUI Improvements Design + +## 1. Header Bar with Keyboard Shortcuts + +Replace the plain title with a full-width header bar. Title left-aligned (cyan, bold), keyboard shortcuts right-aligned (dimmed gray) on the same line. + +``` +DJ — Codex TUI Visualizer n: new Enter: open ?: help t: tree Ctrl+B: prefix +``` + +Use `lipgloss.Place` with left/right alignment to compose the two halves into one row. + +## 2. `n` Key Spawns and Opens a New Session + +Pressing `n` creates a new thread, adds it to the store, pins it, spawns a blank PTY, and focuses the session pane automatically. + +Flow: +1. Generate a unique thread ID and incrementing title ("Session 1", "Session 2", etc.) +2. Add to `ThreadStore` +3. Move canvas selection to the new thread +4. Pin it, spawn PTY, focus session pane (reuse `openSession` logic) + +The `ThreadCreatedMsg` handler chains into the open-session sequence rather than just updating the store. + +## 3. Full-Height Layout with Centered, Scaled Cards + +The TUI fills the terminal. The status bar anchors to the bottom. Cards scale to fill and center within the canvas area. + +Height budget: +- Header: 1 line +- Canvas: terminal height - header - status bar (or split with session panel when pinned) +- Status bar: 1 line + +Card scaling: +- `cardWidth = (canvasWidth - columnGaps) / canvasColumns` +- `cardHeight = (canvasHeight - rowGaps) / numRows` +- Minimum clamp: 20 wide, 4 tall + +Centering: +- `lipgloss.Place(width, canvasHeight, lipgloss.Center, lipgloss.Center, grid)` centers the card grid both horizontally and vertically. + +Canvas receives width/height so it can compute dynamic card sizes. Card styles become functions rather than constants. From e8e2667dd7726928edc6afb50713d9b0aa3257ad Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 12:38:43 -0400 Subject: [PATCH 2/7] docs: expand design into implementation plan 9 tasks covering header bar, n-key session spawning, and full-height centered card layout with TDD steps. --- .../2026-03-18-tui-improvements-design.md | 688 +++++++++++++++++- 1 file changed, 662 insertions(+), 26 deletions(-) diff --git a/docs/plans/2026-03-18-tui-improvements-design.md b/docs/plans/2026-03-18-tui-improvements-design.md index 5eab154..de5a4f1 100644 --- a/docs/plans/2026-03-18-tui-improvements-design.md +++ b/docs/plans/2026-03-18-tui-improvements-design.md @@ -1,42 +1,678 @@ -# TUI Improvements Design +# TUI Improvements Implementation Plan -## 1. Header Bar with Keyboard Shortcuts +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -Replace the plain title with a full-width header bar. Title left-aligned (cyan, bold), keyboard shortcuts right-aligned (dimmed gray) on the same line. +**Goal:** Add a header shortcuts bar, make `n` spawn+open sessions, and make the TUI full-height with centered, scaled cards. +**Architecture:** Three independent UI improvements to the Bubble Tea TUI. The header bar is a new render function. The `n` key change modifies `createThread` and `ThreadCreatedMsg` handling to chain into the existing `openSession` flow. The full-height layout changes card sizing from constants to dynamic functions and wraps the canvas grid in `lipgloss.Place` for centering. + +**Tech Stack:** Go, Bubble Tea, Lipgloss + +--- + +### Task 1: Header Bar — Test + +**Files:** +- Create: `internal/tui/header_test.go` + +**Step 1: Write the failing test** + +```go +package tui + +import ( + "strings" + "testing" +) + +func TestHeaderBarRendersTitle(t *testing.T) { + header := NewHeaderBar(80) + output := header.View() + + if !strings.Contains(output, "DJ") { + t.Errorf("expected title in header, got:\n%s", output) + } +} + +func TestHeaderBarRendersShortcuts(t *testing.T) { + header := NewHeaderBar(80) + output := header.View() + + if !strings.Contains(output, "n: new") { + t.Errorf("expected shortcut hints in header, got:\n%s", output) + } +} + +func TestHeaderBarFitsWidth(t *testing.T) { + header := NewHeaderBar(120) + output := header.View() + + lines := strings.Split(output, "\n") + for _, line := range lines { + if len(line) > 120 { + t.Errorf("header exceeds width 120: len=%d", len(line)) + } + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui -run TestHeaderBar -v` +Expected: FAIL — `NewHeaderBar` undefined + +--- + +### Task 2: Header Bar — Implementation + +**Files:** +- Create: `internal/tui/header.go` +- Modify: `internal/tui/app_view.go:10-16` (replace titleStyle and title rendering) + +**Step 1: Create header.go** + +```go +package tui + +import "github.com/charmbracelet/lipgloss" + +var ( + headerTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("39")) + headerHintStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")) +) + +const headerTitle = "DJ — Codex TUI Visualizer" + +var headerHints = []string{ + "n: new", + "Enter: open", + "?: help", + "t: tree", + "Ctrl+B: prefix", +} + +type HeaderBar struct { + width int +} + +func NewHeaderBar(width int) HeaderBar { + return HeaderBar{width: width} +} + +func (header *HeaderBar) SetWidth(width int) { + header.width = width +} + +func (header HeaderBar) View() string { + title := headerTitleStyle.Render(headerTitle) + + hints := "" + for index, hint := range headerHints { + if index > 0 { + hints += " " + } + hints += hint + } + renderedHints := headerHintStyle.Render(hints) + + return lipgloss.NewStyle().Width(header.width).Render( + lipgloss.JoinHorizontal(lipgloss.Top, title, " ", renderedHints), + ) +} +``` + +Wait — `lipgloss.Place` is better here for left/right alignment. Revised: + +```go +func (header HeaderBar) View() string { + title := headerTitleStyle.Render(headerTitle) + + hints := "" + for index, hint := range headerHints { + if index > 0 { + hints += " " + } + hints += hint + } + renderedHints := headerHintStyle.Render(hints) + + leftRight := title + renderedHints + return lipgloss.PlaceHorizontal(header.width, lipgloss.Left, title, + lipgloss.WithWhitespaceChars(" ")) + "\r" + + lipgloss.PlaceHorizontal(header.width, lipgloss.Right, renderedHints, + lipgloss.WithWhitespaceChars(" ")) +} +``` + +Actually, the simplest approach: render title left-aligned, render hints right-aligned, pad the gap with spaces. + +```go +func (header HeaderBar) View() string { + title := headerTitleStyle.Render(headerTitle) + + hints := "" + for index, hint := range headerHints { + if index > 0 { + hints += " " + } + hints += hint + } + renderedHints := headerHintStyle.Render(hints) + + gap := header.width - lipgloss.Width(title) - lipgloss.Width(renderedHints) + if gap < 1 { + gap = 1 + } + padding := lipgloss.NewStyle().Width(gap).Render("") + return title + padding + renderedHints +} +``` + +**Step 2: Update app_view.go** + +Remove the `titleStyle` variable (lines 10-13). Replace `title := titleStyle.Render(...)` with usage of the new HeaderBar. + +In `app_view.go`, change: +```go +// Remove: +var titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("39")). + MarginBottom(1) + +// In View(): +// Replace: title := titleStyle.Render("DJ — Codex TUI Visualizer") +// With: title := app.header.View() +``` + +Add `header HeaderBar` field to `AppModel` in `app.go:16-39`. Initialize in `NewAppModel`. Update width in the `tea.WindowSizeMsg` handler alongside `statusBar.SetWidth`. + +**Step 3: Run tests** + +Run: `go test ./internal/tui -run TestHeaderBar -v` +Expected: PASS + +**Step 4: Run all tests to check for regressions** + +Run: `go test ./internal/tui -v` +Expected: All pass (some tests check for "DJ" in view output — the title is still present) + +**Step 5: Commit** + +```bash +git add internal/tui/header.go internal/tui/header_test.go internal/tui/app.go internal/tui/app_view.go +git commit -m "feat: add header bar with keyboard shortcut hints" +``` + +--- + +### Task 3: `n` Key Spawns Session — Test + +**Files:** +- Modify: `internal/tui/app_test.go` + +**Step 1: Write the failing test** + +Add to `app_test.go`: + +```go +func TestAppNewThreadCreatesAndOpensSession(t *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store, WithInteractiveCommand("cat")) + app.width = 120 + app.height = 40 + + nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} + updated, cmd := app.Update(nKey) + app = updated.(AppModel) + + if cmd == nil { + t.Fatal("expected command from n key") + } + + msg := cmd() + updated, _ = app.Update(msg) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + threads := store.All() + if len(threads) != 1 { + t.Fatalf("expected 1 thread, got %d", len(threads)) + } + + if app.FocusPane() != FocusPaneSession { + t.Errorf("expected session focus after new thread, got %d", app.FocusPane()) + } + + if len(app.sessionPanel.PinnedSessions()) != 1 { + t.Errorf("expected 1 pinned session, got %d", len(app.sessionPanel.PinnedSessions())) + } +} + +func TestAppNewThreadIncrementsTitle(t *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store, WithInteractiveCommand("cat")) + app.width = 120 + app.height = 40 + + nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} + + updated, cmd := app.Update(nKey) + app = updated.(AppModel) + msg := cmd() + updated, _ = app.Update(msg) + app = updated.(AppModel) + + updated, cmd = app.Update(nKey) + app = updated.(AppModel) + msg = cmd() + updated, _ = app.Update(msg) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + threads := store.All() + if len(threads) != 2 { + t.Fatalf("expected 2 threads, got %d", len(threads)) + } + if threads[0].Title != "Session 1" { + t.Errorf("expected 'Session 1', got %s", threads[0].Title) + } + if threads[1].Title != "Session 2" { + t.Errorf("expected 'Session 2', got %s", threads[1].Title) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui -run TestAppNewThread -v` +Expected: FAIL — new thread doesn't open session or increment titles + +--- + +### Task 4: `n` Key Spawns Session — Implementation + +**Files:** +- Modify: `internal/tui/app.go:16-39` (add `sessionCounter` field) +- Modify: `internal/tui/app.go:128-132` (`ThreadCreatedMsg` handler) +- Modify: `internal/tui/app.go:187-197` (`createThread` function) + +**Step 1: Add sessionCounter to AppModel** + +In `app.go`, add `sessionCounter int` field to `AppModel` struct. + +**Step 2: Update createThread to generate proper IDs** + +Replace `createThread` (lines 187-197): + +```go +func (app *AppModel) createThread() tea.Cmd { + app.sessionCounter++ + counter := app.sessionCounter + return func() tea.Msg { + return ThreadCreatedMsg{ + ThreadID: fmt.Sprintf("session-%d", counter), + Title: fmt.Sprintf("Session %d", counter), + } + } +} ``` -DJ — Codex TUI Visualizer n: new Enter: open ?: help t: tree Ctrl+B: prefix + +Note: this removes the `app.client == nil` guard — `n` always creates a local session now. Add `"fmt"` import if not present. + +**Step 3: Update ThreadCreatedMsg handler to chain into openSession** + +Replace the `ThreadCreatedMsg` case in `Update()` (lines 128-132): + +```go +case ThreadCreatedMsg: + app.store.Add(msg.ThreadID, msg.Title) + app.statusBar.SetThreadCount(len(app.store.All())) + app.canvas.SetSelected(len(app.store.All()) - 1) + return app.openSession() ``` -Use `lipgloss.Place` with left/right alignment to compose the two halves into one row. +**Step 4: Add SetSelected to CanvasModel** + +In `canvas.go`, add: + +```go +func (canvas *CanvasModel) SetSelected(index int) { + threads := canvas.store.All() + if index >= 0 && index < len(threads) { + canvas.selected = index + } +} +``` + +**Step 5: Run tests** + +Run: `go test ./internal/tui -run TestAppNewThread -v` +Expected: PASS + +**Step 6: Update existing TestAppNewThread test** + +The existing `TestAppNewThread` (line 351) and `TestAppHandlesThreadCreatedMsg` (line 363) need updating since behavior changed. `TestAppNewThread` should verify the cmd produces a `ThreadCreatedMsg`. `TestAppHandlesThreadCreatedMsg` now expects a pinned session — update or split the test. + +**Step 7: Run all tests** + +Run: `go test ./internal/tui -v` +Expected: All pass + +**Step 8: Commit** + +```bash +git add internal/tui/app.go internal/tui/app_test.go internal/tui/canvas.go +git commit -m "feat: n key spawns new session and auto-opens it" +``` + +--- + +### Task 5: Full-Height Layout — Test + +**Files:** +- Modify: `internal/tui/canvas_test.go` +- Modify: `internal/tui/card_test.go` + +**Step 1: Write failing tests for dynamic card sizing** + +Add to `card_test.go`: + +```go +func TestCardDynamicSize(t *testing.T) { + thread := state.NewThreadState("t-1", "Test") + thread.Status = state.StatusActive + + card := NewCardModel(thread, false) + card.SetSize(50, 10) + output := card.View() + + if !strings.Contains(output, "Test") { + t.Errorf("expected title in dynamic card, got:\n%s", output) + } +} +``` + +Add to `canvas_test.go`: + +```go +func TestCanvasViewWithDimensions(t *testing.T) { + store := state.NewThreadStore() + store.Add("t-1", "First") + store.Add("t-2", "Second") + store.Add("t-3", "Third") + + canvas := NewCanvasModel(store) + canvas.SetDimensions(120, 30) + output := canvas.View() + + if !strings.Contains(output, "First") { + t.Errorf("expected First in output:\n%s", output) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/tui -run "TestCardDynamic|TestCanvasViewWithDimensions" -v` +Expected: FAIL — `SetSize`/`SetDimensions` undefined + +--- + +### Task 6: Full-Height Layout — Card Scaling + +**Files:** +- Modify: `internal/tui/card.go` + +**Step 1: Make card sizes dynamic** + +Replace fixed `cardWidth`/`cardHeight` constants with fields on `CardModel`. Keep the constants as defaults/minimums. + +```go +const ( + minCardWidth = 20 + minCardHeight = 4 +) + +type CardModel struct { + thread *state.ThreadState + selected bool + width int + height int +} + +func NewCardModel(thread *state.ThreadState, selected bool) CardModel { + return CardModel{ + thread: thread, + selected: selected, + width: minCardWidth, + height: minCardHeight, + } +} + +func (card *CardModel) SetSize(width int, height int) { + if width < minCardWidth { + width = minCardWidth + } + if height < minCardHeight { + height = minCardHeight + } + card.width = width + card.height = height +} + +func (card CardModel) View() string { + statusColor, exists := statusColors[card.thread.Status] + if !exists { + statusColor = defaultStatusColor + } + + statusLine := lipgloss.NewStyle(). + Foreground(statusColor). + Render(card.thread.Status) + + title := truncate(card.thread.Title, card.width-4) + content := fmt.Sprintf("%s\n%s", title, statusLine) + + style := lipgloss.NewStyle(). + Width(card.width). + Height(card.height). + Border(lipgloss.RoundedBorder()). + Padding(0, 1) + + if card.selected { + style = style. + Border(lipgloss.DoubleBorder()). + BorderForeground(lipgloss.Color("39")) + } + + return style.Render(content) +} +``` + +Remove the old `cardStyle` and `selectedCardStyle` package-level vars. + +**Step 2: Run card tests** + +Run: `go test ./internal/tui -run TestCard -v` +Expected: PASS + +--- + +### Task 7: Full-Height Layout — Canvas Centering + +**Files:** +- Modify: `internal/tui/canvas.go` + +**Step 1: Add dimensions and centering to CanvasModel** + +```go +type CanvasModel struct { + store *state.ThreadStore + selected int + width int + height int +} + +func (canvas *CanvasModel) SetDimensions(width int, height int) { + canvas.width = width + canvas.height = height +} +``` + +**Step 2: Update View() to use dynamic sizing and centering** + +```go +func (canvas *CanvasModel) View() string { + threads := canvas.store.All() + if len(threads) == 0 { + return lipgloss.Place(canvas.width, canvas.height, + lipgloss.Center, lipgloss.Center, + "No active threads. Press 'n' to create one.") + } + + numRows := (len(threads) + canvasColumns - 1) / canvasColumns + cardWidth, cardHeight := canvas.cardDimensions(numRows) + + var rows []string + for rowStart := 0; rowStart < len(threads); rowStart += canvasColumns { + rowEnd := rowStart + canvasColumns + if rowEnd > len(threads) { + rowEnd = len(threads) + } + + var cards []string + for index := rowStart; index < rowEnd; index++ { + isSelected := index == canvas.selected + card := NewCardModel(threads[index], isSelected) + card.SetSize(cardWidth, cardHeight) + cards = append(cards, card.View()) + } + + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, cards...)) + } + + grid := strings.Join(rows, "\n") + return lipgloss.Place(canvas.width, canvas.height, + lipgloss.Center, lipgloss.Center, grid) +} + +func (canvas CanvasModel) cardDimensions(numRows int) (int, int) { + columnGap := 0 + rowGap := 1 + + cardWidth := (canvas.width - columnGap*(canvasColumns-1)) / canvasColumns + if cardWidth < minCardWidth { + cardWidth = minCardWidth + } + + totalRowGaps := rowGap * (numRows - 1) + cardHeight := (canvas.height - totalRowGaps) / numRows + if cardHeight < minCardHeight { + cardHeight = minCardHeight + } + + return cardWidth, cardHeight +} +``` + +**Step 3: Run canvas tests** + +Run: `go test ./internal/tui -run TestCanvas -v` +Expected: PASS + +--- + +### Task 8: Full-Height Layout — View Composition + +**Files:** +- Modify: `internal/tui/app_view.go` + +**Step 1: Update View() for full-height layout** + +The key change: compute available canvas height, pass dimensions to canvas, use `lipgloss.JoinVertical` to stack header + canvas + status bar across the full terminal height. + +```go +const ( + headerHeight = 1 + statusBarHeight = 1 +) + +func (app AppModel) View() string { + header := app.header.View() + status := app.statusBar.View() + + if app.helpVisible { + return header + "\n" + app.help.View() + "\n" + status + } + + if app.menuVisible { + return header + "\n" + app.menu.View() + "\n" + status + } + + hasPinned := len(app.sessionPanel.PinnedSessions()) > 0 + + if hasPinned { + canvasHeight := int(float64(app.height) * app.sessionPanel.SplitRatio()) - headerHeight - statusBarHeight + if canvasHeight < 1 { + canvasHeight = 1 + } + app.canvas.SetDimensions(app.width, canvasHeight) + canvas := app.renderCanvas() + divider := app.renderDivider() + panel := app.renderSessionPanel() + return header + "\n" + canvas + "\n" + divider + "\n" + panel + "\n" + status + } + + canvasHeight := app.height - headerHeight - statusBarHeight + if canvasHeight < 1 { + canvasHeight = 1 + } + app.canvas.SetDimensions(app.width, canvasHeight) + canvas := app.renderCanvas() + return header + "\n" + canvas + "\n" + status +} +``` + +**Step 2: Run all tests** + +Run: `go test ./internal/tui -v` +Expected: All pass + +**Step 3: Run linter** + +Run: `golangci-lint run ./internal/tui/...` +Expected: No new issues (check funlen, file length) + +**Step 4: Commit** + +```bash +git add internal/tui/card.go internal/tui/card_test.go internal/tui/canvas.go internal/tui/canvas_test.go internal/tui/app_view.go +git commit -m "feat: full-height layout with centered, scaled cards" +``` -## 2. `n` Key Spawns and Opens a New Session +--- -Pressing `n` creates a new thread, adds it to the store, pins it, spawns a blank PTY, and focuses the session pane automatically. +### Task 9: Integration Test and Final Verification -Flow: -1. Generate a unique thread ID and incrementing title ("Session 1", "Session 2", etc.) -2. Add to `ThreadStore` -3. Move canvas selection to the new thread -4. Pin it, spawn PTY, focus session pane (reuse `openSession` logic) +**Step 1: Build the binary** -The `ThreadCreatedMsg` handler chains into the open-session sequence rather than just updating the store. +Run: `go build -o dj ./cmd/dj` +Expected: Builds cleanly -## 3. Full-Height Layout with Centered, Scaled Cards +**Step 2: Run full test suite with race detector** -The TUI fills the terminal. The status bar anchors to the bottom. Cards scale to fill and center within the canvas area. +Run: `go test ./... -v -race` +Expected: All pass -Height budget: -- Header: 1 line -- Canvas: terminal height - header - status bar (or split with session panel when pinned) -- Status bar: 1 line +**Step 3: Run linter** -Card scaling: -- `cardWidth = (canvasWidth - columnGaps) / canvasColumns` -- `cardHeight = (canvasHeight - rowGaps) / numRows` -- Minimum clamp: 20 wide, 4 tall +Run: `golangci-lint run` +Expected: Clean -Centering: -- `lipgloss.Place(width, canvasHeight, lipgloss.Center, lipgloss.Center, grid)` centers the card grid both horizontally and vertically. +**Step 4: Commit any fixups** -Canvas receives width/height so it can compute dynamic card sizes. Card styles become functions rather than constants. +If any fixes were needed, commit them. From 3d4ac4197f095972d888d56729749f6e0bda8cf3 Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 12:46:18 -0400 Subject: [PATCH 3/7] feat: full-height layout with centered, scaled cards --- internal/tui/app_view.go | 26 ++++++++++++++++--- internal/tui/canvas.go | 51 +++++++++++++++++++++++++++++++++++-- internal/tui/canvas_test.go | 16 ++++++++++++ internal/tui/card.go | 45 +++++++++++++++++++------------- internal/tui/card_test.go | 13 ++++++++++ 5 files changed, 128 insertions(+), 23 deletions(-) diff --git a/internal/tui/app_view.go b/internal/tui/app_view.go index 92226ff..89917c9 100644 --- a/internal/tui/app_view.go +++ b/internal/tui/app_view.go @@ -7,6 +7,11 @@ import ( "github.com/charmbracelet/lipgloss" ) +const ( + headerHeight = 1 + statusBarHeight = 1 +) + var titleStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("39")). @@ -24,13 +29,28 @@ func (app AppModel) View() string { return title + "\n" + app.menu.View() + "\n" + status } - canvas := app.renderCanvas() hasPinned := len(app.sessionPanel.PinnedSessions()) > 0 - if !hasPinned { - return title + "\n" + canvas + "\n" + status + if hasPinned { + return app.renderSplitView(title, status) } + canvasHeight := app.height - headerHeight - statusBarHeight + if canvasHeight < 1 { + canvasHeight = 1 + } + app.canvas.SetDimensions(app.width, canvasHeight) + canvas := app.renderCanvas() + return title + "\n" + canvas + "\n" + status +} + +func (app AppModel) renderSplitView(title string, status string) string { + canvasHeight := int(float64(app.height)*app.sessionPanel.SplitRatio()) - headerHeight - statusBarHeight + if canvasHeight < 1 { + canvasHeight = 1 + } + app.canvas.SetDimensions(app.width, canvasHeight) + canvas := app.renderCanvas() divider := app.renderDivider() panel := app.renderSessionPanel() return title + "\n" + canvas + "\n" + divider + "\n" + panel + "\n" + status diff --git a/internal/tui/canvas.go b/internal/tui/canvas.go index e8f047e..3181562 100644 --- a/internal/tui/canvas.go +++ b/internal/tui/canvas.go @@ -7,17 +7,27 @@ import ( "github.com/robinojw/dj/internal/state" ) -const canvasColumns = 3 +const ( + canvasColumns = 3 + rowGap = 1 +) type CanvasModel struct { store *state.ThreadStore selected int + width int + height int } func NewCanvasModel(store *state.ThreadStore) CanvasModel { return CanvasModel{store: store} } +func (canvas *CanvasModel) SetDimensions(width int, height int) { + canvas.width = width + canvas.height = height +} + func (canvas *CanvasModel) SelectedIndex() int { return canvas.selected } @@ -61,9 +71,26 @@ func (canvas *CanvasModel) MoveUp() { func (canvas *CanvasModel) View() string { threads := canvas.store.All() if len(threads) == 0 { - return "No active threads. Press 'n' to create one." + emptyMessage := "No active threads. Press 'n' to create one." + if canvas.width > 0 && canvas.height > 0 { + return lipgloss.Place(canvas.width, canvas.height, + lipgloss.Center, lipgloss.Center, emptyMessage) + } + return emptyMessage } + grid := canvas.renderGrid(threads) + if canvas.width > 0 && canvas.height > 0 { + return lipgloss.Place(canvas.width, canvas.height, + lipgloss.Center, lipgloss.Center, grid) + } + return grid +} + +func (canvas *CanvasModel) renderGrid(threads []*state.ThreadState) string { + numRows := (len(threads) + canvasColumns - 1) / canvasColumns + cardWidth, cardHeight := canvas.cardDimensions(numRows) + var rows []string for rowStart := 0; rowStart < len(threads); rowStart += canvasColumns { rowEnd := rowStart + canvasColumns @@ -75,6 +102,7 @@ func (canvas *CanvasModel) View() string { for index := rowStart; index < rowEnd; index++ { isSelected := index == canvas.selected card := NewCardModel(threads[index], isSelected) + card.SetSize(cardWidth, cardHeight) cards = append(cards, card.View()) } @@ -83,3 +111,22 @@ func (canvas *CanvasModel) View() string { return strings.Join(rows, "\n") } + +func (canvas CanvasModel) cardDimensions(numRows int) (int, int) { + if canvas.width == 0 || canvas.height == 0 { + return minCardWidth, minCardHeight + } + + cardWidth := canvas.width / canvasColumns + if cardWidth < minCardWidth { + cardWidth = minCardWidth + } + + totalRowGaps := rowGap * (numRows - 1) + cardHeight := (canvas.height - totalRowGaps) / numRows + if cardHeight < minCardHeight { + cardHeight = minCardHeight + } + + return cardWidth, cardHeight +} diff --git a/internal/tui/canvas_test.go b/internal/tui/canvas_test.go index 05c4d6f..d3128b1 100644 --- a/internal/tui/canvas_test.go +++ b/internal/tui/canvas_test.go @@ -1,6 +1,7 @@ package tui import ( + "strings" "testing" "github.com/robinojw/dj/internal/state" @@ -75,3 +76,18 @@ func TestCanvasEmptyStore(t *testing.T) { t.Errorf("expected 0 for empty canvas") } } + +func TestCanvasViewWithDimensions(t *testing.T) { + store := state.NewThreadStore() + store.Add("t-1", "First") + store.Add("t-2", "Second") + store.Add("t-3", "Third") + + canvas := NewCanvasModel(store) + canvas.SetDimensions(120, 30) + output := canvas.View() + + if !strings.Contains(output, "First") { + t.Errorf("expected First in output:\n%s", output) + } +} diff --git a/internal/tui/card.go b/internal/tui/card.go index b530c59..9a988db 100644 --- a/internal/tui/card.go +++ b/internal/tui/card.go @@ -8,24 +8,11 @@ import ( ) const ( - cardWidth = 30 - cardHeight = 6 + minCardWidth = 20 + minCardHeight = 4 ) var ( - cardStyle = lipgloss.NewStyle(). - Width(cardWidth). - Height(cardHeight). - Border(lipgloss.RoundedBorder()). - Padding(0, 1) - - selectedCardStyle = lipgloss.NewStyle(). - Width(cardWidth). - Height(cardHeight). - Border(lipgloss.DoubleBorder()). - BorderForeground(lipgloss.Color("39")). - Padding(0, 1) - statusColors = map[string]lipgloss.Color{ state.StatusActive: lipgloss.Color("42"), state.StatusIdle: lipgloss.Color("245"), @@ -39,15 +26,30 @@ var ( type CardModel struct { thread *state.ThreadState selected bool + width int + height int } func NewCardModel(thread *state.ThreadState, selected bool) CardModel { return CardModel{ thread: thread, selected: selected, + width: minCardWidth, + height: minCardHeight, } } +func (card *CardModel) SetSize(width int, height int) { + if width < minCardWidth { + width = minCardWidth + } + if height < minCardHeight { + height = minCardHeight + } + card.width = width + card.height = height +} + func (card CardModel) View() string { statusColor, exists := statusColors[card.thread.Status] if !exists { @@ -58,12 +60,19 @@ func (card CardModel) View() string { Foreground(statusColor). Render(card.thread.Status) - title := truncate(card.thread.Title, cardWidth-4) + title := truncate(card.thread.Title, card.width-4) content := fmt.Sprintf("%s\n%s", title, statusLine) - style := cardStyle + style := lipgloss.NewStyle(). + Width(card.width). + Height(card.height). + Border(lipgloss.RoundedBorder()). + Padding(0, 1) + if card.selected { - style = selectedCardStyle + style = style. + Border(lipgloss.DoubleBorder()). + BorderForeground(lipgloss.Color("39")) } return style.Render(content) diff --git a/internal/tui/card_test.go b/internal/tui/card_test.go index 3fef7a8..92b6cc9 100644 --- a/internal/tui/card_test.go +++ b/internal/tui/card_test.go @@ -43,3 +43,16 @@ func TestCardRenderSelectedHighlight(t *testing.T) { t.Error("selected and unselected cards should differ") } } + +func TestCardDynamicSize(t *testing.T) { + thread := state.NewThreadState("t-1", "Test") + thread.Status = state.StatusActive + + card := NewCardModel(thread, false) + card.SetSize(50, 10) + output := card.View() + + if !strings.Contains(output, "Test") { + t.Errorf("expected title in dynamic card, got:\n%s", output) + } +} From 7e99fdff78e6f96ba8e71e41abd6146fb2abc46c Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 12:46:20 -0400 Subject: [PATCH 4/7] feat: add header bar with keyboard shortcut hints --- internal/tui/app.go | 3 ++ internal/tui/app_view.go | 7 +---- internal/tui/header.go | 55 +++++++++++++++++++++++++++++++++++++ internal/tui/header_test.go | 38 +++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 internal/tui/header.go create mode 100644 internal/tui/header_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 561e676..fceb6a8 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -35,6 +35,7 @@ type AppModel struct { ptyEvents chan PTYOutputMsg interactiveCmd string interactiveArgs []string + header HeaderBar sessionPanel SessionPanelModel } @@ -49,6 +50,7 @@ func NewAppModel(store *state.ThreadStore, opts ...AppOption) AppModel { events: make(chan appserver.ProtoEvent, eventChannelSize), ptySessions: make(map[string]*PTYSession), ptyEvents: make(chan PTYOutputMsg, eventChannelSize), + header: NewHeaderBar(0), sessionPanel: NewSessionPanelModel(), } for _, opt := range opts { @@ -102,6 +104,7 @@ func (app AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: app.width = msg.Width app.height = msg.Height + app.header.SetWidth(msg.Width) app.statusBar.SetWidth(msg.Width) return app, app.rebalancePTYSizes() case protoEventMsg: diff --git a/internal/tui/app_view.go b/internal/tui/app_view.go index 92226ff..cc024b6 100644 --- a/internal/tui/app_view.go +++ b/internal/tui/app_view.go @@ -7,13 +7,8 @@ import ( "github.com/charmbracelet/lipgloss" ) -var titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("39")). - MarginBottom(1) - func (app AppModel) View() string { - title := titleStyle.Render("DJ — Codex TUI Visualizer") + title := app.header.View() status := app.statusBar.View() if app.helpVisible { diff --git a/internal/tui/header.go b/internal/tui/header.go new file mode 100644 index 0000000..16b02ad --- /dev/null +++ b/internal/tui/header.go @@ -0,0 +1,55 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +var ( + headerTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("39")) + headerHintStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")) +) + +const headerTitle = "DJ — Codex TUI Visualizer" + +var headerHints = []string{ + "n: new", + "Enter: open", + "?: help", + "t: tree", + "Ctrl+B: prefix", +} + +const headerHintSeparator = " " + +type HeaderBar struct { + width int +} + +func NewHeaderBar(width int) HeaderBar { + return HeaderBar{width: width} +} + +func (header *HeaderBar) SetWidth(width int) { + header.width = width +} + +func (header HeaderBar) View() string { + title := headerTitleStyle.Render(headerTitle) + + hints := "" + for index, hint := range headerHints { + if index > 0 { + hints += headerHintSeparator + } + hints += hint + } + renderedHints := headerHintStyle.Render(hints) + + gap := header.width - lipgloss.Width(title) - lipgloss.Width(renderedHints) + if gap < 1 { + gap = 1 + } + padding := lipgloss.NewStyle().Width(gap).Render("") + return title + padding + renderedHints +} diff --git a/internal/tui/header_test.go b/internal/tui/header_test.go new file mode 100644 index 0000000..5850da5 --- /dev/null +++ b/internal/tui/header_test.go @@ -0,0 +1,38 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" +) + +func TestHeaderBarRendersTitle(t *testing.T) { + header := NewHeaderBar(80) + output := header.View() + + if !strings.Contains(output, "DJ") { + t.Errorf("expected title in header, got:\n%s", output) + } +} + +func TestHeaderBarRendersShortcuts(t *testing.T) { + header := NewHeaderBar(80) + output := header.View() + + if !strings.Contains(output, "n: new") { + t.Errorf("expected shortcut hints in header, got:\n%s", output) + } +} + +func TestHeaderBarFitsWidth(t *testing.T) { + header := NewHeaderBar(120) + output := header.View() + + lines := strings.Split(output, "\n") + for _, line := range lines { + if lipgloss.Width(line) > 120 { + t.Errorf("header exceeds width 120: len=%d", lipgloss.Width(line)) + } + } +} From fa4276cfbf8940b63e92721f54cd80defcd103b8 Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 12:46:25 -0400 Subject: [PATCH 5/7] feat: n key spawns new session and auto-opens it --- internal/tui/app.go | 40 +++++++++++--------- internal/tui/app_test.go | 80 +++++++++++++++++++++++++++++++++++++++- internal/tui/canvas.go | 8 ++++ 3 files changed, 108 insertions(+), 20 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 561e676..66e3116 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1,6 +1,8 @@ package tui import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/robinojw/dj/internal/appserver" "github.com/robinojw/dj/internal/state" @@ -33,6 +35,7 @@ type AppModel struct { events chan appserver.ProtoEvent ptySessions map[string]*PTYSession ptyEvents chan PTYOutputMsg + sessionCounter *int interactiveCmd string interactiveArgs []string sessionPanel SessionPanelModel @@ -40,16 +43,17 @@ type AppModel struct { func NewAppModel(store *state.ThreadStore, opts ...AppOption) AppModel { app := AppModel{ - store: store, - statusBar: NewStatusBar(), - canvas: NewCanvasModel(store), - tree: NewTreeModel(store), - prefix: NewPrefixHandler(), - help: NewHelpModel(), - events: make(chan appserver.ProtoEvent, eventChannelSize), - ptySessions: make(map[string]*PTYSession), - ptyEvents: make(chan PTYOutputMsg, eventChannelSize), - sessionPanel: NewSessionPanelModel(), + store: store, + statusBar: NewStatusBar(), + canvas: NewCanvasModel(store), + tree: NewTreeModel(store), + prefix: NewPrefixHandler(), + help: NewHelpModel(), + events: make(chan appserver.ProtoEvent, eventChannelSize), + ptySessions: make(map[string]*PTYSession), + ptyEvents: make(chan PTYOutputMsg, eventChannelSize), + sessionCounter: new(int), + sessionPanel: NewSessionPanelModel(), } for _, opt := range opts { opt(&app) @@ -128,7 +132,8 @@ func (app AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ThreadCreatedMsg: app.store.Add(msg.ThreadID, msg.Title) app.statusBar.SetThreadCount(len(app.store.All())) - return app, nil + app.canvas.SetSelected(len(app.store.All()) - 1) + return app.openSession() } return app, nil } @@ -185,15 +190,14 @@ func (app AppModel) handleRune(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (app AppModel) createThread() tea.Cmd { - if app.client == nil { - return func() tea.Msg { - return ThreadCreatedMsg{ - ThreadID: "local", - Title: "New Thread", - } + *app.sessionCounter++ + counter := *app.sessionCounter + return func() tea.Msg { + return ThreadCreatedMsg{ + ThreadID: fmt.Sprintf("session-%d", counter), + Title: fmt.Sprintf("Session %d", counter), } } - return nil } func (app AppModel) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 4db1156..31b1098 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -362,11 +362,14 @@ func TestAppNewThread(t *testing.T) { func TestAppHandlesThreadCreatedMsg(t *testing.T) { store := state.NewThreadStore() - app := NewAppModel(store) + app := NewAppModel(store, WithInteractiveCommand("cat")) + app.width = 120 + app.height = 40 msg := ThreadCreatedMsg{ThreadID: "t-new", Title: "New Thread"} updated, _ := app.Update(msg) - _ = updated.(AppModel) + appModel := updated.(AppModel) + defer appModel.StopAllPTYSessions() threads := store.All() if len(threads) != 1 { @@ -375,6 +378,9 @@ func TestAppHandlesThreadCreatedMsg(t *testing.T) { if threads[0].ID != "t-new" { t.Errorf("expected thread t-new, got %s", threads[0].ID) } + if appModel.FocusPane() != FocusPaneSession { + t.Errorf("expected session focus, got %d", appModel.FocusPane()) + } } func TestAppHandlesAgentDeltaWithoutSession(t *testing.T) { @@ -671,3 +677,73 @@ func TestAppHasPinnedSessions(t *testing.T) { t.Errorf("expected 0 pinned sessions, got %d", len(app.sessionPanel.PinnedSessions())) } } + +func TestAppNewThreadCreatesAndOpensSession(t *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store, WithInteractiveCommand("cat")) + app.width = 120 + app.height = 40 + + nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} + updated, cmd := app.Update(nKey) + app = updated.(AppModel) + + if cmd == nil { + t.Fatal("expected command from n key") + } + + msg := cmd() + updated, _ = app.Update(msg) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + threads := store.All() + if len(threads) != 1 { + t.Fatalf("expected 1 thread, got %d", len(threads)) + } + + if app.FocusPane() != FocusPaneSession { + t.Errorf("expected session focus after new thread, got %d", app.FocusPane()) + } + + if len(app.sessionPanel.PinnedSessions()) != 1 { + t.Errorf("expected 1 pinned session, got %d", len(app.sessionPanel.PinnedSessions())) + } +} + +func TestAppNewThreadIncrementsTitle(t *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store, WithInteractiveCommand("cat")) + app.width = 120 + app.height = 40 + + nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} + escKey := tea.KeyMsg{Type: tea.KeyEsc} + + updated, cmd := app.Update(nKey) + app = updated.(AppModel) + msg := cmd() + updated, _ = app.Update(msg) + app = updated.(AppModel) + + updated, _ = app.Update(escKey) + app = updated.(AppModel) + + updated, cmd = app.Update(nKey) + app = updated.(AppModel) + msg = cmd() + updated, _ = app.Update(msg) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + threads := store.All() + if len(threads) != 2 { + t.Fatalf("expected 2 threads, got %d", len(threads)) + } + if threads[0].Title != "Session 1" { + t.Errorf("expected 'Session 1', got %s", threads[0].Title) + } + if threads[1].Title != "Session 2" { + t.Errorf("expected 'Session 2', got %s", threads[1].Title) + } +} diff --git a/internal/tui/canvas.go b/internal/tui/canvas.go index e8f047e..1953188 100644 --- a/internal/tui/canvas.go +++ b/internal/tui/canvas.go @@ -30,6 +30,14 @@ func (canvas *CanvasModel) SelectedThreadID() string { return threads[canvas.selected].ID } +func (canvas *CanvasModel) SetSelected(index int) { + threads := canvas.store.All() + isValidIndex := index >= 0 && index < len(threads) + if isValidIndex { + canvas.selected = index + } +} + func (canvas *CanvasModel) MoveRight() { threads := canvas.store.All() if canvas.selected < len(threads)-1 { From c7e372877307cc4d940717fa40d0176115f5987d Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 15:29:47 -0400 Subject: [PATCH 6/7] feat: add real-time activity indicators to session cards Cards now show what each CLI session is doing instead of just a status word. Activity updates in real-time from protocol events: "Thinking..." during reasoning, streaming text snippets during responses, "Running: " during exec, and "Applying patch..." during patches. --- ...2026-03-18-session-card-activity-design.md | 50 ++ .../2026-03-18-session-card-activity-plan.md | 551 ++++++++++++++++++ internal/state/store.go | 11 + internal/state/store_test.go | 95 +-- internal/state/thread.go | 9 + internal/state/thread_test.go | 80 ++- internal/tui/app.go | 2 + internal/tui/app_proto.go | 52 +- internal/tui/bridge.go | 10 + internal/tui/bridge_test.go | 66 ++- internal/tui/card.go | 28 +- internal/tui/card_test.go | 80 ++- internal/tui/msgs.go | 4 + 13 files changed, 926 insertions(+), 112 deletions(-) create mode 100644 docs/plans/2026-03-18-session-card-activity-design.md create mode 100644 docs/plans/2026-03-18-session-card-activity-plan.md diff --git a/docs/plans/2026-03-18-session-card-activity-design.md b/docs/plans/2026-03-18-session-card-activity-design.md new file mode 100644 index 0000000..f3d2159 --- /dev/null +++ b/docs/plans/2026-03-18-session-card-activity-design.md @@ -0,0 +1,50 @@ +# Session Card Activity Indicators + +## Problem + +Session cards show only a static status word (idle/active/completed/error). Users cannot tell what the CLI is actually doing — whether it's thinking, running a command, applying a patch, or streaming a response. + +## Design + +An `Activity` field on `ThreadState` tracks the current action for each session. The card renders activity instead of the status word when present. When there is no activity, the card falls back to the existing status display. + +## Activity Mapping + +| Protocol Event | Activity Text | Behavior | +|---|---|---| +| `task_started` | `Thinking...` | Set on task start | +| `agent_reasoning_delta` | `Thinking...` | Set during reasoning | +| `agent_message_delta` | Truncated snippet of streaming text | Updated with each delta | +| `exec_command_request` | `Running: ` | Truncated to card width | +| `patch_apply_request` | `Applying patch...` | Set on patch request | +| `agent_message` (completed) | Clears activity | Falls back to status | +| `task_complete` | Clears activity | Falls back to status | + +## Card Rendering + +Activity replaces the status line when present: + +``` +Active with activity: +┌──────────────────────────────┐ +│ o3-mini │ +│ Running: git status │ +└──────────────────────────────┘ + +Idle (no activity): +┌──────────────────────────────┐ +│ o3-mini │ +│ idle │ +└──────────────────────────────┘ +``` + +Activity text uses the same color as the current status. + +## Changes + +- `state/thread.go`: Add `Activity` field, `SetActivity()`, `ClearActivity()` methods +- `state/store.go`: Add `UpdateActivity(id, activity)` method (thread-safe) +- `tui/card.go`: Prefer `thread.Activity` over `thread.Status` for card second line +- `tui/app_proto.go`: Set activity in event handlers +- `tui/bridge.go`: Handle `agent_reasoning_delta` events +- `tui/msgs.go`: Add `AgentReasoningDeltaMsg` type diff --git a/docs/plans/2026-03-18-session-card-activity-plan.md b/docs/plans/2026-03-18-session-card-activity-plan.md new file mode 100644 index 0000000..7353aba --- /dev/null +++ b/docs/plans/2026-03-18-session-card-activity-plan.md @@ -0,0 +1,551 @@ +# Session Card Activity Indicators — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Show real-time activity on session cards (e.g. "Running: git status", "Thinking...", streaming text snippets) by adding an `Activity` field to `ThreadState` and rendering it on cards in place of the status word. + +**Architecture:** Protocol event handlers set an `Activity` string on the thread via the store. The card's `View()` method checks `Activity` first — if non-empty, renders it with status color instead of the status word. Activity is cleared on `agent_message` (completed) and `task_complete`. + +**Tech Stack:** Go, Bubble Tea, Lipgloss, standard `testing` package. + +--- + +### Task 1: Add Activity field and methods to ThreadState + +**Files:** +- Modify: `internal/state/thread.go` +- Modify: `internal/state/thread_test.go` + +**Step 1: Write the failing tests** + +Add to `internal/state/thread_test.go`: + +```go +func TestThreadStateSetActivity(t *testing.T) { + thread := NewThreadState("t-1", "Test") + thread.SetActivity("Running: git status") + + if thread.Activity != "Running: git status" { + t.Errorf("expected Running: git status, got %s", thread.Activity) + } +} + +func TestThreadStateClearActivity(t *testing.T) { + thread := NewThreadState("t-1", "Test") + thread.SetActivity("Thinking...") + thread.ClearActivity() + + if thread.Activity != "" { + t.Errorf("expected empty activity, got %s", thread.Activity) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/state -run TestThreadState(Set|Clear)Activity -v` +Expected: FAIL — `thread.Activity undefined`, `thread.SetActivity undefined` + +**Step 3: Write minimal implementation** + +Add `Activity` field to `ThreadState` struct and two methods in `internal/state/thread.go`: + +```go +type ThreadState struct { + ID string + Title string + Status string + Activity string + ParentID string + Messages []ChatMessage + CommandOutput map[string]string +} + +func (threadState *ThreadState) SetActivity(activity string) { + threadState.Activity = activity +} + +func (threadState *ThreadState) ClearActivity() { + threadState.Activity = "" +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/state -run TestThreadState(Set|Clear)Activity -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/state/thread.go internal/state/thread_test.go +git commit -m "feat: add Activity field to ThreadState" +``` + +--- + +### Task 2: Add UpdateActivity to ThreadStore + +**Files:** +- Modify: `internal/state/store.go` +- Modify: `internal/state/store_test.go` + +**Step 1: Write the failing tests** + +Add to `internal/state/store_test.go`: + +```go +func TestStoreUpdateActivity(t *testing.T) { + store := NewThreadStore() + store.Add("t-1", "Test") + store.UpdateActivity("t-1", "Thinking...") + + thread, _ := store.Get("t-1") + if thread.Activity != "Thinking..." { + t.Errorf("expected Thinking..., got %s", thread.Activity) + } +} + +func TestStoreUpdateActivityMissing(t *testing.T) { + store := NewThreadStore() + store.UpdateActivity("missing", "Thinking...") +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/state -run TestStoreUpdateActivity -v` +Expected: FAIL — `store.UpdateActivity undefined` + +**Step 3: Write minimal implementation** + +Add to `internal/state/store.go`: + +```go +func (store *ThreadStore) UpdateActivity(id string, activity string) { + store.mu.Lock() + defer store.mu.Unlock() + + thread, exists := store.threads[id] + if !exists { + return + } + thread.Activity = activity +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/state -run TestStoreUpdateActivity -v` +Expected: PASS + +**Step 5: Run full state package tests** + +Run: `go test ./internal/state -v` +Expected: All PASS + +**Step 6: Commit** + +```bash +git add internal/state/store.go internal/state/store_test.go +git commit -m "feat: add UpdateActivity to ThreadStore" +``` + +--- + +### Task 3: Render activity on session cards + +**Files:** +- Modify: `internal/tui/card.go` +- Modify: `internal/tui/card_test.go` + +**Step 1: Write the failing tests** + +Add to `internal/tui/card_test.go`: + +```go +func TestCardRenderShowsActivity(t *testing.T) { + thread := state.NewThreadState("t-1", "o3-mini") + thread.Status = state.StatusActive + thread.Activity = "Running: git status" + + card := NewCardModel(thread, false) + output := card.View() + + if !strings.Contains(output, "Running: git status") { + t.Errorf("expected activity in output, got:\n%s", output) + } +} + +func TestCardRenderFallsBackToStatus(t *testing.T) { + thread := state.NewThreadState("t-1", "o3-mini") + thread.Status = state.StatusIdle + + card := NewCardModel(thread, false) + output := card.View() + + hasActivity := strings.Contains(output, "Running") + if hasActivity { + t.Errorf("expected no activity when idle, got:\n%s", output) + } + if !strings.Contains(output, "idle") { + t.Errorf("expected status fallback, got:\n%s", output) + } +} + +func TestCardRenderActivityTruncated(t *testing.T) { + thread := state.NewThreadState("t-1", "o3-mini") + thread.Status = state.StatusActive + thread.Activity = "Running: npm install --save-dev @types/react @types/react-dom" + + card := NewCardModel(thread, false) + card.SetSize(minCardWidth, minCardHeight) + output := card.View() + + if !strings.Contains(output, "...") { + t.Errorf("expected truncated activity, got:\n%s", output) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/tui -run TestCardRender(ShowsActivity|FallsBack|ActivityTruncated) -v` +Expected: FAIL — activity text not present in output + +**Step 3: Write minimal implementation** + +Modify `card.View()` in `internal/tui/card.go`. Replace the `statusLine` logic with an activity-aware check: + +```go +func (card CardModel) View() string { + statusColor, exists := statusColors[card.thread.Status] + if !exists { + statusColor = defaultStatusColor + } + + secondLine := card.thread.Status + hasActivity := card.thread.Activity != "" + if hasActivity { + secondLine = card.thread.Activity + } + + styledSecondLine := lipgloss.NewStyle(). + Foreground(statusColor). + Render(truncate(secondLine, card.width-cardBorderPadding)) + + titleMaxLen := card.width - cardBorderPadding + title := truncate(card.thread.Title, titleMaxLen) + content := fmt.Sprintf("%s\n%s", title, styledSecondLine) + + style := lipgloss.NewStyle(). + Width(card.width). + Height(card.height). + Border(lipgloss.RoundedBorder()). + Padding(0, 1) + + if card.selected { + style = style. + Border(lipgloss.DoubleBorder()). + BorderForeground(lipgloss.Color("39")) + } + + return style.Render(content) +} +``` + +Note: This removes the pinned indicator rendering since the canvas does not use it (the session panel handles pinned display separately via the divider bar). + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/tui -run TestCardRender -v` +Expected: All PASS (including existing `TestCardRenderShowsTitle`, `TestCardRenderShowsStatus`, `TestCardRenderSelectedHighlight`, `TestCardDynamicSize`) + +**Step 5: Commit** + +```bash +git add internal/tui/card.go internal/tui/card_test.go +git commit -m "feat: render activity on session cards" +``` + +--- + +### Task 4: Add AgentReasoningDeltaMsg to bridge + +**Files:** +- Modify: `internal/tui/msgs.go` +- Modify: `internal/tui/bridge.go` +- Modify: `internal/tui/bridge_test.go` + +**Step 1: Write the failing test** + +Add to `internal/tui/bridge_test.go`: + +```go +func TestBridgeAgentReasoningDelta(t *testing.T) { + event := appserver.ProtoEvent{ + Msg: json.RawMessage(`{"type":"agent_reasoning_delta","delta":"Let me think..."}`), + } + msg := ProtoEventToMsg(event) + reasoning, ok := msg.(AgentReasoningDeltaMsg) + if !ok { + t.Fatalf("expected AgentReasoningDeltaMsg, got %T", msg) + } + if reasoning.Delta != "Let me think..." { + t.Errorf("expected Let me think..., got %s", reasoning.Delta) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui -run TestBridgeAgentReasoningDelta -v` +Expected: FAIL — `AgentReasoningDeltaMsg` undefined + +**Step 3: Write minimal implementation** + +Add to `internal/tui/msgs.go`: + +```go +type AgentReasoningDeltaMsg struct { + Delta string +} +``` + +Add case to switch in `ProtoEventToMsg` in `internal/tui/bridge.go`: + +```go +case appserver.EventAgentReasonDelta: + return decodeReasoningDelta(event.Msg) +``` + +Add decoder function to `internal/tui/bridge.go`: + +```go +func decodeReasoningDelta(raw json.RawMessage) tea.Msg { + var delta appserver.AgentDelta + if err := json.Unmarshal(raw, &delta); err != nil { + return nil + } + return AgentReasoningDeltaMsg{Delta: delta.Delta} +} +``` + +Note: Reuses `appserver.AgentDelta` struct since reasoning deltas have the same `{"delta":"..."}` JSON shape. + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/tui -run TestBridge -v` +Expected: All PASS + +**Step 5: Commit** + +```bash +git add internal/tui/msgs.go internal/tui/bridge.go internal/tui/bridge_test.go +git commit -m "feat: decode agent_reasoning_delta events in bridge" +``` + +--- + +### Task 5: Wire event handlers to set activity + +**Files:** +- Modify: `internal/tui/app_proto.go` +- Modify: `internal/tui/app.go` (add Update case for new msg type) + +**Step 1: Check current Update switch for msg routing** + +Read `internal/tui/app.go` to find the `Update` method's switch statement and identify where to add the new `AgentReasoningDeltaMsg` case. + +**Step 2: Update handleTaskStarted to set activity** + +In `internal/tui/app_proto.go`, modify `handleTaskStarted`: + +```go +func (app AppModel) handleTaskStarted() (tea.Model, tea.Cmd) { + if app.sessionID == "" { + return app, nil + } + app.store.UpdateStatus(app.sessionID, state.StatusActive, "") + app.store.UpdateActivity(app.sessionID, "Thinking...") + messageID := fmt.Sprintf("msg-%d", time.Now().UnixNano()) + app.currentMessageID = messageID + thread, exists := app.store.Get(app.sessionID) + if exists { + thread.AppendMessage(state.ChatMessage{ + ID: messageID, + Role: "assistant", + Content: "", + }) + } + return app, nil +} +``` + +**Step 3: Update handleAgentDelta to set activity snippet** + +In `internal/tui/app_proto.go`, modify `handleAgentDelta`. After appending the delta, update activity with a snippet of the latest message content: + +```go +func (app AppModel) handleAgentDelta(msg AgentDeltaMsg) (tea.Model, tea.Cmd) { + noSession := app.sessionID == "" + noMessage := app.currentMessageID == "" + if noSession || noMessage { + return app, nil + } + thread, exists := app.store.Get(app.sessionID) + if !exists { + return app, nil + } + thread.AppendDelta(app.currentMessageID, msg.Delta) + snippet := latestMessageSnippet(thread, app.currentMessageID) + app.store.UpdateActivity(app.sessionID, snippet) + return app, nil +} +``` + +**Step 4: Add latestMessageSnippet helper** + +Add to `internal/tui/app_proto.go`: + +```go +const activitySnippetMaxLen = 40 + +func latestMessageSnippet(thread *state.ThreadState, messageID string) string { + for index := range thread.Messages { + if thread.Messages[index].ID != messageID { + continue + } + content := thread.Messages[index].Content + if len(content) <= activitySnippetMaxLen { + return content + } + return content[len(content)-activitySnippetMaxLen:] + } + return "" +} +``` + +**Step 5: Add handleReasoningDelta handler** + +Add to `internal/tui/app_proto.go`: + +```go +func (app AppModel) handleReasoningDelta() (tea.Model, tea.Cmd) { + if app.sessionID != "" { + app.store.UpdateActivity(app.sessionID, "Thinking...") + } + return app, nil +} +``` + +**Step 6: Update handleExecApproval to set activity** + +In `internal/tui/app_proto.go`, modify `handleExecApproval`: + +```go +func (app AppModel) handleExecApproval(msg ExecApprovalRequestMsg) (tea.Model, tea.Cmd) { + if app.sessionID != "" { + activity := fmt.Sprintf("Running: %s", msg.Command) + app.store.UpdateActivity(app.sessionID, activity) + } + if app.client != nil { + app.client.SendApproval(msg.EventID, appserver.OpExecApproval, true) + } + return app, nil +} +``` + +**Step 7: Update handlePatchApproval to set activity** + +In `internal/tui/app_proto.go`, modify `handlePatchApproval`: + +```go +func (app AppModel) handlePatchApproval(msg PatchApprovalRequestMsg) (tea.Model, tea.Cmd) { + if app.sessionID != "" { + app.store.UpdateActivity(app.sessionID, "Applying patch...") + } + if app.client != nil { + app.client.SendApproval(msg.EventID, appserver.OpPatchApproval, true) + } + return app, nil +} +``` + +**Step 8: Update handleAgentMessageCompleted to clear activity** + +In `internal/tui/app_proto.go`, modify `handleAgentMessageCompleted`: + +```go +func (app AppModel) handleAgentMessageCompleted() (tea.Model, tea.Cmd) { + app.currentMessageID = "" + if app.sessionID != "" { + app.store.UpdateActivity(app.sessionID, "") + } + return app, nil +} +``` + +**Step 9: Update handleTaskComplete to clear activity** + +In `internal/tui/app_proto.go`, modify `handleTaskComplete`: + +```go +func (app AppModel) handleTaskComplete() (tea.Model, tea.Cmd) { + if app.sessionID != "" { + app.store.UpdateStatus(app.sessionID, state.StatusCompleted, "") + app.store.UpdateActivity(app.sessionID, "") + } + app.currentMessageID = "" + return app, nil +} +``` + +**Step 10: Add AgentReasoningDeltaMsg case to Update in app.go** + +Find the switch statement in `app.go`'s `Update` method and add: + +```go +case AgentReasoningDeltaMsg: + return app.handleReasoningDelta() +``` + +**Step 11: Run all tests** + +Run: `go test ./... -v -race` +Expected: All PASS + +**Step 12: Run linter** + +Run: `golangci-lint run` +Expected: No issues + +**Step 13: Commit** + +```bash +git add internal/tui/app_proto.go internal/tui/app.go +git commit -m "feat: wire protocol events to session card activity" +``` + +--- + +### Task 6: Verify build and full test suite + +**Step 1: Build** + +Run: `go build -o dj ./cmd/dj` +Expected: Build succeeds + +**Step 2: Full test suite with race detector** + +Run: `go test ./... -v -race` +Expected: All PASS + +**Step 3: Lint** + +Run: `golangci-lint run` +Expected: No issues + +**Step 4: Final commit if any cleanup needed** + +Only if lint or tests revealed issues to fix. diff --git a/internal/state/store.go b/internal/state/store.go index 682d0e9..0c013f2 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -91,6 +91,17 @@ func (store *ThreadStore) UpdateStatus(id string, status string, title string) { } } +func (store *ThreadStore) UpdateActivity(id string, activity string) { + store.mu.Lock() + defer store.mu.Unlock() + + thread, exists := store.threads[id] + if !exists { + return + } + thread.Activity = activity +} + func (store *ThreadStore) Children(parentID string) []*ThreadState { store.mu.RLock() defer store.mu.RUnlock() diff --git a/internal/state/store_test.go b/internal/state/store_test.go index a128c12..f765092 100644 --- a/internal/state/store_test.go +++ b/internal/state/store_test.go @@ -2,75 +2,104 @@ package state import "testing" -func TestStoreAddAndGet(t *testing.T) { +const ( + storeTestThreadID = "t-1" + storeTestSecondID = "t-2" + storeTestMissingID = "missing" + storeTestMyThread = "My Thread" + storeTestTitle = "Test" + storeTestFirstTitle = "First" + storeTestSecondTitle = "Second" + storeTestRunning = "Running" + storeTestActivity = "Running: git status" + storeTestExpectedTwo = 2 +) + +func TestStoreAddAndGet(testing *testing.T) { store := NewThreadStore() - store.Add("t-1", "My Thread") + store.Add(storeTestThreadID, storeTestMyThread) - thread, exists := store.Get("t-1") + thread, exists := store.Get(storeTestThreadID) if !exists { - t.Fatal("expected thread to exist") + testing.Fatal("expected thread to exist") } - if thread.Title != "My Thread" { - t.Errorf("expected My Thread, got %s", thread.Title) + if thread.Title != storeTestMyThread { + testing.Errorf("expected My Thread, got %s", thread.Title) } } -func TestStoreGetMissing(t *testing.T) { +func TestStoreGetMissing(testing *testing.T) { store := NewThreadStore() - _, exists := store.Get("missing") + _, exists := store.Get(storeTestMissingID) if exists { - t.Error("expected thread to not exist") + testing.Error("expected thread to not exist") } } -func TestStoreDelete(t *testing.T) { +func TestStoreDelete(testing *testing.T) { store := NewThreadStore() - store.Add("t-1", "Test") - store.Delete("t-1") + store.Add(storeTestThreadID, storeTestTitle) + store.Delete(storeTestThreadID) - _, exists := store.Get("t-1") + _, exists := store.Get(storeTestThreadID) if exists { - t.Error("expected thread to be deleted") + testing.Error("expected thread to be deleted") } } -func TestStoreAll(t *testing.T) { +func TestStoreAll(testing *testing.T) { store := NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") + store.Add(storeTestThreadID, storeTestFirstTitle) + store.Add(storeTestSecondID, storeTestSecondTitle) all := store.All() - if len(all) != 2 { - t.Fatalf("expected 2 threads, got %d", len(all)) + if len(all) != storeTestExpectedTwo { + testing.Fatalf("expected 2 threads, got %d", len(all)) } } -func TestStoreUpdateStatus(t *testing.T) { +func TestStoreUpdateStatus(testing *testing.T) { store := NewThreadStore() - store.Add("t-1", "Test") - store.UpdateStatus("t-1", StatusActive, "Running") + store.Add(storeTestThreadID, storeTestTitle) + store.UpdateStatus(storeTestThreadID, StatusActive, storeTestRunning) - thread, _ := store.Get("t-1") + thread, _ := store.Get(storeTestThreadID) if thread.Status != StatusActive { - t.Errorf("expected active, got %s", thread.Status) + testing.Errorf("expected active, got %s", thread.Status) } - if thread.Title != "Running" { - t.Errorf("expected Running, got %s", thread.Title) + if thread.Title != storeTestRunning { + testing.Errorf("expected Running, got %s", thread.Title) } } -func TestStoreUpdateStatusMissing(t *testing.T) { +func TestStoreUpdateStatusMissing(testing *testing.T) { store := NewThreadStore() - store.UpdateStatus("missing", StatusActive, "Test") + store.UpdateStatus(storeTestMissingID, StatusActive, storeTestTitle) } -func TestStoreIDs(t *testing.T) { +func TestStoreIDs(testing *testing.T) { store := NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") + store.Add(storeTestThreadID, storeTestFirstTitle) + store.Add(storeTestSecondID, storeTestSecondTitle) ids := store.IDs() - if len(ids) != 2 { - t.Fatalf("expected 2 ids, got %d", len(ids)) + if len(ids) != storeTestExpectedTwo { + testing.Fatalf("expected 2 ids, got %d", len(ids)) } } + +func TestStoreUpdateActivity(testing *testing.T) { + store := NewThreadStore() + store.Add(storeTestThreadID, storeTestTitle) + store.UpdateActivity(storeTestThreadID, storeTestActivity) + + thread, _ := store.Get(storeTestThreadID) + if thread.Activity != storeTestActivity { + testing.Errorf("expected %s, got %s", storeTestActivity, thread.Activity) + } +} + +func TestStoreUpdateActivityMissing(testing *testing.T) { + store := NewThreadStore() + store.UpdateActivity(storeTestMissingID, storeTestActivity) +} diff --git a/internal/state/thread.go b/internal/state/thread.go index 4690e96..8b21d81 100644 --- a/internal/state/thread.go +++ b/internal/state/thread.go @@ -17,6 +17,7 @@ type ThreadState struct { ID string Title string Status string + Activity string ParentID string Messages []ChatMessage CommandOutput map[string]string @@ -48,3 +49,11 @@ func (threadState *ThreadState) AppendDelta(messageID string, delta string) { func (threadState *ThreadState) AppendOutput(execID string, data string) { threadState.CommandOutput[execID] += data } + +func (threadState *ThreadState) SetActivity(activity string) { + threadState.Activity = activity +} + +func (threadState *ThreadState) ClearActivity() { + threadState.Activity = "" +} diff --git a/internal/state/thread_test.go b/internal/state/thread_test.go index 724e7b5..dd156bd 100644 --- a/internal/state/thread_test.go +++ b/internal/state/thread_test.go @@ -2,51 +2,81 @@ package state import "testing" -func TestNewThreadState(t *testing.T) { - thread := NewThreadState("t-1", "Build a web app") - if thread.ID != "t-1" { - t.Errorf("expected t-1, got %s", thread.ID) +const ( + testThreadID = "t-1" + testTitle = "Test" + testMessageID = "m-1" + testExecID = "e-1" + testGreeting = "Hello" + testActivity = "Running: git status" + + errExpectedHello = "expected Hello, got %s" +) + +func TestNewThreadState(testing *testing.T) { + thread := NewThreadState(testThreadID, "Build a web app") + if thread.ID != testThreadID { + testing.Errorf("expected t-1, got %s", thread.ID) } if thread.Status != StatusIdle { - t.Errorf("expected idle, got %s", thread.Status) + testing.Errorf("expected idle, got %s", thread.Status) } if len(thread.Messages) != 0 { - t.Errorf("expected 0 messages, got %d", len(thread.Messages)) + testing.Errorf("expected 0 messages, got %d", len(thread.Messages)) } } -func TestThreadStateAppendMessage(t *testing.T) { - thread := NewThreadState("t-1", "Test") +func TestThreadStateAppendMessage(testing *testing.T) { + thread := NewThreadState(testThreadID, testTitle) thread.AppendMessage(ChatMessage{ - ID: "m-1", + ID: testMessageID, Role: "user", - Content: "Hello", + Content: testGreeting, }) if len(thread.Messages) != 1 { - t.Fatalf("expected 1 message, got %d", len(thread.Messages)) + testing.Fatalf("expected 1 message, got %d", len(thread.Messages)) } - if thread.Messages[0].Content != "Hello" { - t.Errorf("expected Hello, got %s", thread.Messages[0].Content) + if thread.Messages[0].Content != testGreeting { + testing.Errorf(errExpectedHello, thread.Messages[0].Content) } } -func TestThreadStateAppendDelta(t *testing.T) { - thread := NewThreadState("t-1", "Test") - thread.AppendMessage(ChatMessage{ID: "m-1", Role: "assistant", Content: "He"}) - thread.AppendDelta("m-1", "llo") +func TestThreadStateAppendDelta(testing *testing.T) { + thread := NewThreadState(testThreadID, testTitle) + thread.AppendMessage(ChatMessage{ID: testMessageID, Role: "assistant", Content: "He"}) + thread.AppendDelta(testMessageID, "llo") - if thread.Messages[0].Content != "Hello" { - t.Errorf("expected Hello, got %s", thread.Messages[0].Content) + if thread.Messages[0].Content != testGreeting { + testing.Errorf(errExpectedHello, thread.Messages[0].Content) } } -func TestThreadStateAppendOutput(t *testing.T) { - thread := NewThreadState("t-1", "Test") - thread.AppendOutput("e-1", "line 1\n") - thread.AppendOutput("e-1", "line 2\n") +func TestThreadStateAppendOutput(testing *testing.T) { + thread := NewThreadState(testThreadID, testTitle) + thread.AppendOutput(testExecID, "line 1\n") + thread.AppendOutput(testExecID, "line 2\n") - output := thread.CommandOutput["e-1"] + output := thread.CommandOutput[testExecID] if output != "line 1\nline 2\n" { - t.Errorf("expected combined output, got %q", output) + testing.Errorf("expected combined output, got %q", output) + } +} + +func TestThreadStateSetActivity(testing *testing.T) { + thread := NewThreadState(testThreadID, testTitle) + thread.SetActivity(testActivity) + + if thread.Activity != testActivity { + testing.Errorf("expected %s, got %s", testActivity, thread.Activity) + } +} + +func TestThreadStateClearActivity(testing *testing.T) { + thread := NewThreadState(testThreadID, testTitle) + thread.SetActivity("Thinking...") + thread.ClearActivity() + + if thread.Activity != "" { + testing.Errorf("expected empty activity, got %s", thread.Activity) } } diff --git a/internal/tui/app.go b/internal/tui/app.go index f0e9568..3488580 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -152,6 +152,8 @@ func (app AppModel) handleAgentMsg(msg tea.Msg) (tea.Model, tea.Cmd) { return app.handleExecApproval(msg) case PatchApprovalRequestMsg: return app.handlePatchApproval(msg) + case AgentReasoningDeltaMsg: + return app.handleReasoningDelta() } return app, nil } diff --git a/internal/tui/app_proto.go b/internal/tui/app_proto.go index 8c5db17..f4b38a3 100644 --- a/internal/tui/app_proto.go +++ b/internal/tui/app_proto.go @@ -10,6 +10,13 @@ import ( "github.com/robinojw/dj/internal/state" ) +const ( + activityThinking = "Thinking..." + activityApplyingPatch = "Applying patch..." + activityRunningPrefix = "Running: " + activitySnippetMaxLen = 40 +) + type protoEventMsg struct { Event appserver.ProtoEvent } @@ -64,6 +71,7 @@ func (app AppModel) handleTaskStarted() (tea.Model, tea.Cmd) { return app, nil } app.store.UpdateStatus(app.sessionID, state.StatusActive, "") + app.store.UpdateActivity(app.sessionID, activityThinking) messageID := fmt.Sprintf("msg-%d", time.Now().UnixNano()) app.currentMessageID = messageID thread, exists := app.store.Get(app.sessionID) @@ -78,32 +86,49 @@ func (app AppModel) handleTaskStarted() (tea.Model, tea.Cmd) { } func (app AppModel) handleAgentDelta(msg AgentDeltaMsg) (tea.Model, tea.Cmd) { - noSession := app.sessionID == "" - noMessage := app.currentMessageID == "" - if noSession || noMessage { + missingContext := app.sessionID == "" || app.currentMessageID == "" + if missingContext { return app, nil } thread, exists := app.store.Get(app.sessionID) - if exists { - thread.AppendDelta(app.currentMessageID, msg.Delta) + if !exists { + return app, nil + } + thread.AppendDelta(app.currentMessageID, msg.Delta) + snippet := latestMessageSnippet(thread, app.currentMessageID) + app.store.UpdateActivity(app.sessionID, snippet) + return app, nil +} + +func (app AppModel) handleReasoningDelta() (tea.Model, tea.Cmd) { + if app.sessionID != "" { + app.store.UpdateActivity(app.sessionID, activityThinking) } return app, nil } func (app AppModel) handleAgentMessageCompleted() (tea.Model, tea.Cmd) { app.currentMessageID = "" + if app.sessionID != "" { + app.store.UpdateActivity(app.sessionID, "") + } return app, nil } func (app AppModel) handleTaskComplete() (tea.Model, tea.Cmd) { if app.sessionID != "" { app.store.UpdateStatus(app.sessionID, state.StatusCompleted, "") + app.store.UpdateActivity(app.sessionID, "") } app.currentMessageID = "" return app, nil } func (app AppModel) handleExecApproval(msg ExecApprovalRequestMsg) (tea.Model, tea.Cmd) { + if app.sessionID != "" { + activity := activityRunningPrefix + msg.Command + app.store.UpdateActivity(app.sessionID, activity) + } if app.client != nil { app.client.SendApproval(msg.EventID, appserver.OpExecApproval, true) } @@ -111,8 +136,25 @@ func (app AppModel) handleExecApproval(msg ExecApprovalRequestMsg) (tea.Model, t } func (app AppModel) handlePatchApproval(msg PatchApprovalRequestMsg) (tea.Model, tea.Cmd) { + if app.sessionID != "" { + app.store.UpdateActivity(app.sessionID, activityApplyingPatch) + } if app.client != nil { app.client.SendApproval(msg.EventID, appserver.OpPatchApproval, true) } return app, nil } + +func latestMessageSnippet(thread *state.ThreadState, messageID string) string { + for index := range thread.Messages { + if thread.Messages[index].ID != messageID { + continue + } + content := thread.Messages[index].Content + if len(content) <= activitySnippetMaxLen { + return content + } + return content[len(content)-activitySnippetMaxLen:] + } + return "" +} diff --git a/internal/tui/bridge.go b/internal/tui/bridge.go index 542c4be..0205bf8 100644 --- a/internal/tui/bridge.go +++ b/internal/tui/bridge.go @@ -25,6 +25,8 @@ func ProtoEventToMsg(event appserver.ProtoEvent) tea.Msg { return decodeAgentDelta(event.Msg) case appserver.EventAgentMessage: return decodeAgentMessage(event.Msg) + case appserver.EventAgentReasonDelta: + return decodeReasoningDelta(event.Msg) case appserver.EventExecApproval: return decodeExecApproval(event) case appserver.EventPatchApproval: @@ -68,6 +70,14 @@ func decodeAgentMessage(raw json.RawMessage) tea.Msg { return AgentMessageCompletedMsg{Message: msg.Message} } +func decodeReasoningDelta(raw json.RawMessage) tea.Msg { + var delta appserver.AgentDelta + if err := json.Unmarshal(raw, &delta); err != nil { + return nil + } + return AgentReasoningDeltaMsg{Delta: delta.Delta} +} + func decodeExecApproval(event appserver.ProtoEvent) tea.Msg { var req appserver.ExecCommandRequest if err := json.Unmarshal(event.Msg, &req); err != nil { diff --git a/internal/tui/bridge_test.go b/internal/tui/bridge_test.go index 6b8f234..f321fdf 100644 --- a/internal/tui/bridge_test.go +++ b/internal/tui/bridge_test.go @@ -7,7 +7,9 @@ import ( "github.com/robinojw/dj/internal/appserver" ) -func TestBridgeSessionConfigured(t *testing.T) { +const testRequestID = "req-1" + +func TestBridgeSessionConfigured(testing *testing.T) { event := appserver.ProtoEvent{ ID: "", Msg: json.RawMessage(`{"type":"session_configured","session_id":"s-1","model":"o4-mini"}`), @@ -15,103 +17,117 @@ func TestBridgeSessionConfigured(t *testing.T) { msg := ProtoEventToMsg(event) configured, ok := msg.(SessionConfiguredMsg) if !ok { - t.Fatalf("expected SessionConfiguredMsg, got %T", msg) + testing.Fatalf("expected SessionConfiguredMsg, got %T", msg) } if configured.SessionID != "s-1" { - t.Errorf("expected s-1, got %s", configured.SessionID) + testing.Errorf("expected s-1, got %s", configured.SessionID) } if configured.Model != "o4-mini" { - t.Errorf("expected o4-mini, got %s", configured.Model) + testing.Errorf("expected o4-mini, got %s", configured.Model) } } -func TestBridgeTaskStarted(t *testing.T) { +func TestBridgeTaskStarted(testing *testing.T) { event := appserver.ProtoEvent{ Msg: json.RawMessage(`{"type":"task_started","model_context_window":200000}`), } msg := ProtoEventToMsg(event) _, ok := msg.(TaskStartedMsg) if !ok { - t.Fatalf("expected TaskStartedMsg, got %T", msg) + testing.Fatalf("expected TaskStartedMsg, got %T", msg) } } -func TestBridgeAgentDelta(t *testing.T) { +func TestBridgeAgentDelta(testing *testing.T) { event := appserver.ProtoEvent{ Msg: json.RawMessage(`{"type":"agent_message_delta","delta":"Hello"}`), } msg := ProtoEventToMsg(event) delta, ok := msg.(AgentDeltaMsg) if !ok { - t.Fatalf("expected AgentDeltaMsg, got %T", msg) + testing.Fatalf("expected AgentDeltaMsg, got %T", msg) } if delta.Delta != "Hello" { - t.Errorf("expected Hello, got %s", delta.Delta) + testing.Errorf("expected Hello, got %s", delta.Delta) } } -func TestBridgeAgentMessage(t *testing.T) { +func TestBridgeAgentMessage(testing *testing.T) { event := appserver.ProtoEvent{ Msg: json.RawMessage(`{"type":"agent_message","message":"Hello world"}`), } msg := ProtoEventToMsg(event) completed, ok := msg.(AgentMessageCompletedMsg) if !ok { - t.Fatalf("expected AgentMessageCompletedMsg, got %T", msg) + testing.Fatalf("expected AgentMessageCompletedMsg, got %T", msg) } if completed.Message != "Hello world" { - t.Errorf("expected Hello world, got %s", completed.Message) + testing.Errorf("expected Hello world, got %s", completed.Message) } } -func TestBridgeTaskComplete(t *testing.T) { +func TestBridgeTaskComplete(testing *testing.T) { event := appserver.ProtoEvent{ Msg: json.RawMessage(`{"type":"task_complete","last_agent_message":"Done"}`), } msg := ProtoEventToMsg(event) complete, ok := msg.(TaskCompleteMsg) if !ok { - t.Fatalf("expected TaskCompleteMsg, got %T", msg) + testing.Fatalf("expected TaskCompleteMsg, got %T", msg) } if complete.LastMessage != "Done" { - t.Errorf("expected Done, got %s", complete.LastMessage) + testing.Errorf("expected Done, got %s", complete.LastMessage) } } -func TestBridgeExecApproval(t *testing.T) { +func TestBridgeExecApproval(testing *testing.T) { event := appserver.ProtoEvent{ - ID: "req-1", + ID: testRequestID, Msg: json.RawMessage(`{"type":"exec_command_request","command":"ls","cwd":"/tmp"}`), } msg := ProtoEventToMsg(event) approval, ok := msg.(ExecApprovalRequestMsg) if !ok { - t.Fatalf("expected ExecApprovalRequestMsg, got %T", msg) + testing.Fatalf("expected ExecApprovalRequestMsg, got %T", msg) } - if approval.EventID != "req-1" { - t.Errorf("expected req-1, got %s", approval.EventID) + if approval.EventID != testRequestID { + testing.Errorf("expected %s, got %s", testRequestID, approval.EventID) } if approval.Command != "ls" { - t.Errorf("expected ls, got %s", approval.Command) + testing.Errorf("expected ls, got %s", approval.Command) + } +} + +func TestBridgeAgentReasoningDelta(testing *testing.T) { + event := appserver.ProtoEvent{ + Msg: json.RawMessage(`{"type":"agent_reasoning_delta","delta":"Let me think..."}`), + } + msg := ProtoEventToMsg(event) + reasoning, ok := msg.(AgentReasoningDeltaMsg) + if !ok { + testing.Fatalf("expected AgentReasoningDeltaMsg, got %T", msg) + } + if reasoning.Delta != "Let me think..." { + testing.Errorf("expected Let me think..., got %s", reasoning.Delta) } } -func TestBridgeUnknownEventReturnsNil(t *testing.T) { +func TestBridgeUnknownEventReturnsNil(testing *testing.T) { event := appserver.ProtoEvent{ Msg: json.RawMessage(`{"type":"unknown_event"}`), } msg := ProtoEventToMsg(event) if msg != nil { - t.Errorf("expected nil for unknown event, got %T", msg) + testing.Errorf("expected nil for unknown event, got %T", msg) } } -func TestBridgeInvalidJSONReturnsNil(t *testing.T) { +func TestBridgeInvalidJSONReturnsNil(testing *testing.T) { event := appserver.ProtoEvent{ Msg: json.RawMessage(`not json`), } msg := ProtoEventToMsg(event) if msg != nil { - t.Errorf("expected nil for invalid JSON, got %T", msg) + testing.Errorf("expected nil for invalid JSON, got %T", msg) } } diff --git a/internal/tui/card.go b/internal/tui/card.go index 9a988db..b62a218 100644 --- a/internal/tui/card.go +++ b/internal/tui/card.go @@ -8,19 +8,22 @@ import ( ) const ( - minCardWidth = 20 - minCardHeight = 4 + minCardWidth = 20 + minCardHeight = 4 + cardBorderPadding = 4 + ellipsisLen = 3 + colorGray = lipgloss.Color("245") ) var ( statusColors = map[string]lipgloss.Color{ state.StatusActive: lipgloss.Color("42"), - state.StatusIdle: lipgloss.Color("245"), + state.StatusIdle: colorGray, state.StatusCompleted: lipgloss.Color("34"), state.StatusError: lipgloss.Color("196"), } - defaultStatusColor = lipgloss.Color("245") + defaultStatusColor = colorGray ) type CardModel struct { @@ -56,12 +59,19 @@ func (card CardModel) View() string { statusColor = defaultStatusColor } - statusLine := lipgloss.NewStyle(). + secondLine := card.thread.Status + hasActivity := card.thread.Activity != "" + if hasActivity { + secondLine = card.thread.Activity + } + + styledSecondLine := lipgloss.NewStyle(). Foreground(statusColor). - Render(card.thread.Status) + Render(truncate(secondLine, card.width-cardBorderPadding)) - title := truncate(card.thread.Title, card.width-4) - content := fmt.Sprintf("%s\n%s", title, statusLine) + titleMaxLen := card.width - cardBorderPadding + title := truncate(card.thread.Title, titleMaxLen) + content := fmt.Sprintf("%s\n%s", title, styledSecondLine) style := lipgloss.NewStyle(). Width(card.width). @@ -82,5 +92,5 @@ func truncate(text string, maxLen int) string { if len(text) <= maxLen { return text } - return text[:maxLen-3] + "..." + return text[:maxLen-ellipsisLen] + "..." } diff --git a/internal/tui/card_test.go b/internal/tui/card_test.go index 92b6cc9..ff946d7 100644 --- a/internal/tui/card_test.go +++ b/internal/tui/card_test.go @@ -7,32 +7,42 @@ import ( "github.com/robinojw/dj/internal/state" ) -func TestCardRenderShowsTitle(t *testing.T) { - thread := state.NewThreadState("t-1", "Build web app") +const ( + testThreadID = "t-1" + testTitleBuild = "Build web app" + testTitleGeneric = "Test" + testActivity = "Running: git status" + testLongActivity = "This is a very long activity string that should definitely be truncated when rendered on a small card" + testLargeWidth = 50 + testLargeHeight = 10 +) + +func TestCardRenderShowsTitle(testing *testing.T) { + thread := state.NewThreadState(testThreadID, testTitleBuild) thread.Status = state.StatusActive card := NewCardModel(thread, false) output := card.View() - if !strings.Contains(output, "Build web app") { - t.Errorf("expected title in output, got:\n%s", output) + if !strings.Contains(output, testTitleBuild) { + testing.Errorf("expected title in output, got:\n%s", output) } } -func TestCardRenderShowsStatus(t *testing.T) { - thread := state.NewThreadState("t-1", "Test") +func TestCardRenderShowsStatus(testing *testing.T) { + thread := state.NewThreadState(testThreadID, testTitleGeneric) thread.Status = state.StatusActive card := NewCardModel(thread, false) output := card.View() if !strings.Contains(output, "active") { - t.Errorf("expected status in output, got:\n%s", output) + testing.Errorf("expected status in output, got:\n%s", output) } } -func TestCardRenderSelectedHighlight(t *testing.T) { - thread := state.NewThreadState("t-1", "Test") +func TestCardRenderSelectedHighlight(testing *testing.T) { + thread := state.NewThreadState(testThreadID, testTitleGeneric) card := NewCardModel(thread, true) selected := card.View() @@ -40,19 +50,59 @@ func TestCardRenderSelectedHighlight(t *testing.T) { unselected := card2.View() if selected == unselected { - t.Error("selected and unselected cards should differ") + testing.Error("selected and unselected cards should differ") + } +} + +func TestCardDynamicSize(testing *testing.T) { + thread := state.NewThreadState(testThreadID, testTitleGeneric) + thread.Status = state.StatusActive + + card := NewCardModel(thread, false) + card.SetSize(testLargeWidth, testLargeHeight) + output := card.View() + + if !strings.Contains(output, testTitleGeneric) { + testing.Errorf("expected title in dynamic card, got:\n%s", output) + } +} + +func TestCardRenderShowsActivity(testing *testing.T) { + thread := state.NewThreadState(testThreadID, testTitleGeneric) + thread.Status = state.StatusActive + thread.Activity = testActivity + + card := NewCardModel(thread, false) + card.SetSize(testLargeWidth, testLargeHeight) + output := card.View() + + if !strings.Contains(output, testActivity) { + testing.Errorf("expected activity in output, got:\n%s", output) + } +} + +func TestCardRenderFallsBackToStatus(testing *testing.T) { + thread := state.NewThreadState(testThreadID, testTitleGeneric) + thread.Status = state.StatusIdle + + card := NewCardModel(thread, false) + output := card.View() + + if !strings.Contains(output, "idle") { + testing.Errorf("expected status fallback in output, got:\n%s", output) } } -func TestCardDynamicSize(t *testing.T) { - thread := state.NewThreadState("t-1", "Test") +func TestCardRenderActivityTruncated(testing *testing.T) { + thread := state.NewThreadState(testThreadID, testTitleGeneric) thread.Status = state.StatusActive + thread.Activity = testLongActivity card := NewCardModel(thread, false) - card.SetSize(50, 10) + card.SetSize(minCardWidth, minCardHeight) output := card.View() - if !strings.Contains(output, "Test") { - t.Errorf("expected title in dynamic card, got:\n%s", output) + if !strings.Contains(output, "...") { + testing.Errorf("expected truncated activity with ellipsis, got:\n%s", output) } } diff --git a/internal/tui/msgs.go b/internal/tui/msgs.go index 30397b0..1affc03 100644 --- a/internal/tui/msgs.go +++ b/internal/tui/msgs.go @@ -19,6 +19,10 @@ type AgentMessageCompletedMsg struct { Message string } +type AgentReasoningDeltaMsg struct { + Delta string +} + type ExecApprovalRequestMsg struct { EventID string Command string From 83fe9780082b7246991dc2af446ffbdecd634f5d Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 15:34:07 -0400 Subject: [PATCH 7/7] chore: remove accidentally staged worktree directory --- .claude/worktrees/cw-20260318-151058-31582 | 1 - 1 file changed, 1 deletion(-) delete mode 160000 .claude/worktrees/cw-20260318-151058-31582 diff --git a/.claude/worktrees/cw-20260318-151058-31582 b/.claude/worktrees/cw-20260318-151058-31582 deleted file mode 160000 index 9cb7fba..0000000 --- a/.claude/worktrees/cw-20260318-151058-31582 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9cb7fba7456e70fbd0ea7f3c6a715af5aec074d8