From bcc4430de1ba061be1ac90b95478451914ac0554 Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 12:36:26 -0400 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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 f060bcd5441f295af96614f4c1d1874854b9fcb2 Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 14:11:44 -0400 Subject: [PATCH 6/6] feat: card sizing constraints, column gaps, and s-key select - Add max card height (12) and max card width (50) to prevent cards from stretching across the full terminal - Add 2-character column gaps between cards in the grid - Add 's' keybinding to toggle session selection (pin/unpin) - Show checkmark indicator on pinned session cards - Extract named constants for magic numbers in card rendering --- internal/tui/app.go | 2 +- internal/tui/app_view.go | 1 + internal/tui/canvas.go | 73 +++++++++++++++++++++++++++++---------- internal/tui/card.go | 31 +++++++++++++---- internal/tui/card_test.go | 61 +++++++++++++++++++++----------- internal/tui/header.go | 1 + 6 files changed, 122 insertions(+), 47 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index f0e9568..0c1f8e7 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -213,7 +213,7 @@ func (app AppModel) handleRune(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return app, app.createThread() case "?": app.helpVisible = !app.helpVisible - case " ": + case " ", "s": return app.togglePin() } return app, nil diff --git a/internal/tui/app_view.go b/internal/tui/app_view.go index ae4389f..2d279fd 100644 --- a/internal/tui/app_view.go +++ b/internal/tui/app_view.go @@ -57,6 +57,7 @@ func (app AppModel) renderSplitView(title string, status string) string { } func (app AppModel) renderCanvas() string { + app.canvas.SetPinnedIDs(app.sessionPanel.PinnedSessions()) canvas := app.canvas.View() if app.canvasMode == CanvasModeTree { treeView := app.tree.View() diff --git a/internal/tui/canvas.go b/internal/tui/canvas.go index b7e8b81..7ad7698 100644 --- a/internal/tui/canvas.go +++ b/internal/tui/canvas.go @@ -10,19 +10,28 @@ import ( const ( canvasColumns = 3 rowGap = 1 + columnGap = 2 ) type CanvasModel struct { - store *state.ThreadStore - selected int - width int - height int + store *state.ThreadStore + selected int + pinnedIDs map[string]bool + width int + height int } func NewCanvasModel(store *state.ThreadStore) CanvasModel { return CanvasModel{store: store} } +func (canvas *CanvasModel) SetPinnedIDs(pinned []string) { + canvas.pinnedIDs = make(map[string]bool, len(pinned)) + for _, id := range pinned { + canvas.pinnedIDs[id] = true + } +} + func (canvas *CanvasModel) SetDimensions(width int, height int) { canvas.width = width canvas.height = height @@ -76,29 +85,42 @@ func (canvas *CanvasModel) MoveUp() { } } +func (canvas *CanvasModel) hasDimensions() bool { + return canvas.width > 0 && canvas.height > 0 +} + +func (canvas *CanvasModel) centerContent(content string) string { + return lipgloss.Place(canvas.width, canvas.height, + lipgloss.Center, lipgloss.Center, content) +} + func (canvas *CanvasModel) View() string { threads := canvas.store.All() if len(threads) == 0 { - 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 + return canvas.renderEmpty() } grid := canvas.renderGrid(threads) - if canvas.width > 0 && canvas.height > 0 { - return lipgloss.Place(canvas.width, canvas.height, - lipgloss.Center, lipgloss.Center, grid) + if canvas.hasDimensions() { + return canvas.centerContent(grid) } return grid } +func (canvas *CanvasModel) renderEmpty() string { + emptyMessage := "No active threads. Press 'n' to create one." + if canvas.hasDimensions() { + return canvas.centerContent(emptyMessage) + } + return emptyMessage +} + func (canvas *CanvasModel) renderGrid(threads []*state.ThreadState) string { numRows := (len(threads) + canvasColumns - 1) / canvasColumns cardWidth, cardHeight := canvas.cardDimensions(numRows) + gapStyle := lipgloss.NewStyle().Width(columnGap) + var rows []string for rowStart := 0; rowStart < len(threads); rowStart += canvasColumns { rowEnd := rowStart + canvasColumns @@ -106,35 +128,48 @@ func (canvas *CanvasModel) renderGrid(threads []*state.ThreadState) string { rowEnd = len(threads) } - var cards []string + var parts []string for index := rowStart; index < rowEnd; index++ { + isNotFirstInRow := index > rowStart + if isNotFirstInRow { + parts = append(parts, gapStyle.Render("")) + } isSelected := index == canvas.selected - card := NewCardModel(threads[index], isSelected) + isPinned := canvas.pinnedIDs[threads[index].ID] + card := NewCardModel(threads[index], isSelected, isPinned) card.SetSize(cardWidth, cardHeight) - cards = append(cards, card.View()) + parts = append(parts, card.View()) } - rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, cards...)) + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, parts...)) } return strings.Join(rows, "\n") } func (canvas CanvasModel) cardDimensions(numRows int) (int, int) { - if canvas.width == 0 || canvas.height == 0 { + missingDimensions := canvas.width == 0 || canvas.height == 0 + if missingDimensions { return minCardWidth, minCardHeight } - cardWidth := canvas.width / canvasColumns + totalColumnGaps := columnGap * (canvasColumns - 1) + cardWidth := (canvas.width - totalColumnGaps) / canvasColumns if cardWidth < minCardWidth { cardWidth = minCardWidth } + if cardWidth > maxCardWidth { + cardWidth = maxCardWidth + } totalRowGaps := rowGap * (numRows - 1) cardHeight := (canvas.height - totalRowGaps) / numRows if cardHeight < minCardHeight { cardHeight = minCardHeight } + if cardHeight > maxCardHeight { + cardHeight = maxCardHeight + } return cardWidth, cardHeight } diff --git a/internal/tui/card.go b/internal/tui/card.go index 9a988db..5603c8f 100644 --- a/internal/tui/card.go +++ b/internal/tui/card.go @@ -8,32 +8,42 @@ import ( ) const ( - minCardWidth = 20 - minCardHeight = 4 + minCardWidth = 20 + maxCardWidth = 50 + minCardHeight = 4 + maxCardHeight = 12 + cardBorderPadding = 4 + truncateEllipsisLen = 3 ) var ( + colorIdle = lipgloss.Color("245") + statusColors = map[string]lipgloss.Color{ state.StatusActive: lipgloss.Color("42"), - state.StatusIdle: lipgloss.Color("245"), + state.StatusIdle: colorIdle, state.StatusCompleted: lipgloss.Color("34"), state.StatusError: lipgloss.Color("196"), } - defaultStatusColor = lipgloss.Color("245") + defaultStatusColor = colorIdle ) +const pinnedIndicator = " ✓" + type CardModel struct { thread *state.ThreadState selected bool + pinned bool width int height int } -func NewCardModel(thread *state.ThreadState, selected bool) CardModel { +func NewCardModel(thread *state.ThreadState, selected bool, pinned bool) CardModel { return CardModel{ thread: thread, selected: selected, + pinned: pinned, width: minCardWidth, height: minCardHeight, } @@ -60,7 +70,14 @@ func (card CardModel) View() string { Foreground(statusColor). Render(card.thread.Status) - title := truncate(card.thread.Title, card.width-4) + titleMaxLen := card.width - cardBorderPadding + if card.pinned { + titleMaxLen -= len(pinnedIndicator) + } + title := truncate(card.thread.Title, titleMaxLen) + if card.pinned { + title += pinnedIndicator + } content := fmt.Sprintf("%s\n%s", title, statusLine) style := lipgloss.NewStyle(). @@ -82,5 +99,5 @@ func truncate(text string, maxLen int) string { if len(text) <= maxLen { return text } - return text[:maxLen-3] + "..." + return text[:maxLen-truncateEllipsisLen] + "..." } diff --git a/internal/tui/card_test.go b/internal/tui/card_test.go index 92b6cc9..b58ea6b 100644 --- a/internal/tui/card_test.go +++ b/internal/tui/card_test.go @@ -7,52 +7,73 @@ import ( "github.com/robinojw/dj/internal/state" ) -func TestCardRenderShowsTitle(t *testing.T) { - thread := state.NewThreadState("t-1", "Build web app") +const ( + testThreadID = "t-1" + testThreadTitle = "Test" + testBuildTitle = "Build web app" + testCardWidth = 50 + testCardHeight = 10 +) + +func TestCardRenderShowsTitle(testing *testing.T) { + thread := state.NewThreadState(testThreadID, testBuildTitle) thread.Status = state.StatusActive - card := NewCardModel(thread, false) + card := NewCardModel(thread, false, 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, testBuildTitle) { + 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, testThreadTitle) thread.Status = state.StatusActive - card := NewCardModel(thread, false) + card := NewCardModel(thread, false, 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") - card := NewCardModel(thread, true) +func TestCardRenderSelectedHighlight(testing *testing.T) { + thread := state.NewThreadState(testThreadID, testThreadTitle) + card := NewCardModel(thread, true, false) selected := card.View() - card2 := NewCardModel(thread, false) + card2 := NewCardModel(thread, false, false) 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, testThreadTitle) + thread.Status = state.StatusActive + + card := NewCardModel(thread, false, false) + card.SetSize(testCardWidth, testCardHeight) + output := card.View() + + if !strings.Contains(output, testThreadTitle) { + testing.Errorf("expected title in dynamic card, got:\n%s", output) } } -func TestCardDynamicSize(t *testing.T) { - thread := state.NewThreadState("t-1", "Test") +func TestCardPinnedShowsIndicator(testing *testing.T) { + thread := state.NewThreadState(testThreadID, testBuildTitle) thread.Status = state.StatusActive - card := NewCardModel(thread, false) - card.SetSize(50, 10) + card := NewCardModel(thread, false, true) + card.SetSize(testCardWidth, testCardHeight) 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 pinned indicator in output, got:\n%s", output) } } diff --git a/internal/tui/header.go b/internal/tui/header.go index 16b02ad..016ef1a 100644 --- a/internal/tui/header.go +++ b/internal/tui/header.go @@ -14,6 +14,7 @@ const headerTitle = "DJ — Codex TUI Visualizer" var headerHints = []string{ "n: new", + "s: select", "Enter: open", "?: help", "t: tree",