diff --git a/.claude/hooks/llm-bouncer/llm-bouncer b/.claude/hooks/llm-bouncer/llm-bouncer new file mode 100755 index 0000000..7fdfd1a Binary files /dev/null and b/.claude/hooks/llm-bouncer/llm-bouncer differ diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..5b644c2 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/llm-bouncer/llm-bouncer" + } + ] + } + ] + } +} \ No newline at end of file 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..de5a4f1 --- /dev/null +++ b/docs/plans/2026-03-18-tui-improvements-design.md @@ -0,0 +1,678 @@ +# TUI Improvements Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**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), + } + } +} +``` + +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() +``` + +**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" +``` + +--- + +### Task 9: Integration Test and Final Verification + +**Step 1: Build the binary** + +Run: `go build -o dj ./cmd/dj` +Expected: Builds cleanly + +**Step 2: Run full test suite with race detector** + +Run: `go test ./... -v -race` +Expected: All pass + +**Step 3: Run linter** + +Run: `golangci-lint run` +Expected: Clean + +**Step 4: Commit any fixups** + +If any fixes were needed, commit them. diff --git a/internal/tui/app.go b/internal/tui/app.go index 561e676..0c1f8e7 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,23 +35,27 @@ type AppModel struct { events chan appserver.ProtoEvent ptySessions map[string]*PTYSession ptyEvents chan PTYOutputMsg + sessionCounter *int interactiveCmd string interactiveArgs []string + header HeaderBar sessionPanel SessionPanelModel } 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), + header: NewHeaderBar(0), + sessionPanel: NewSessionPanelModel(), } for _, opt := range opts { opt(&app) @@ -60,15 +66,15 @@ func NewAppModel(store *state.ThreadStore, opts ...AppOption) AppModel { type AppOption func(*AppModel) func WithClient(client *appserver.Client) AppOption { - return func(a *AppModel) { - a.client = client + return func(app *AppModel) { + app.client = client } } func WithInteractiveCommand(command string, args ...string) AppOption { - return func(a *AppModel) { - a.interactiveCmd = command - a.interactiveArgs = args + return func(app *AppModel) { + app.interactiveCmd = command + app.interactiveArgs = args } } @@ -100,14 +106,38 @@ func (app AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: return app.handleKey(msg) case tea.WindowSizeMsg: - app.width = msg.Width - app.height = msg.Height - app.statusBar.SetWidth(msg.Width) - return app, app.rebalancePTYSizes() + return app.handleWindowSize(msg) case protoEventMsg: return app.handleProtoEvent(msg.Event) case PTYOutputMsg: return app.handlePTYOutput(msg) + case AppServerErrorMsg: + app.statusBar.SetError(msg.Error()) + return app, nil + case ThreadCreatedMsg: + return app.handleThreadCreated(msg) + default: + return app.handleAgentMsg(msg) + } +} + +func (app AppModel) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + app.width = msg.Width + app.height = msg.Height + app.header.SetWidth(msg.Width) + app.statusBar.SetWidth(msg.Width) + return app, app.rebalancePTYSizes() +} + +func (app AppModel) handleThreadCreated(msg ThreadCreatedMsg) (tea.Model, tea.Cmd) { + 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() +} + +func (app AppModel) handleAgentMsg(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { case SessionConfiguredMsg: return app.handleSessionConfigured(msg) case TaskStartedMsg: @@ -122,13 +152,6 @@ func (app AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return app.handleExecApproval(msg) case PatchApprovalRequestMsg: return app.handlePatchApproval(msg) - case AppServerErrorMsg: - app.statusBar.SetError(msg.Error()) - return app, nil - case ThreadCreatedMsg: - app.store.Add(msg.ThreadID, msg.Title) - app.statusBar.SetThreadCount(len(app.store.All())) - return app, nil } return app, nil } @@ -142,20 +165,32 @@ func (app AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return app.handleMenuKey(msg) } + if result, model, cmd := app.handlePrefix(msg); result { + return model, cmd + } + + if app.focusPane == FocusPaneSession { + return app.handleSessionKey(msg) + } + + return app.handleCanvasKey(msg) +} + +func (app AppModel) handlePrefix(msg tea.KeyMsg) (bool, tea.Model, tea.Cmd) { prefixResult := app.prefix.HandleKey(msg) switch prefixResult { case PrefixWaiting: - return app, nil + return true, app, nil case PrefixComplete: - return app.handlePrefixAction() + model, cmd := app.handlePrefixAction() + return true, model, cmd case PrefixCancelled: - return app, nil - } - - if app.focusPane == FocusPaneSession { - return app.handleSessionKey(msg) + return true, app, nil } + return false, app, nil +} +func (app AppModel) handleCanvasKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyCtrlC, tea.KeyEsc: return app, tea.Quit @@ -178,28 +213,28 @@ 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 } 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) { isToggle := msg.Type == tea.KeyRunes && msg.String() == "?" isEsc := msg.Type == tea.KeyEsc - if isToggle || isEsc { + shouldDismissHelp := isToggle || isEsc + if shouldDismissHelp { app.helpVisible = false } return app, nil 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/app_view.go b/internal/tui/app_view.go index 92226ff..2d279fd 100644 --- a/internal/tui/app_view.go +++ b/internal/tui/app_view.go @@ -7,36 +7,57 @@ import ( "github.com/charmbracelet/lipgloss" ) -var titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("39")). - MarginBottom(1) +const ( + headerHeight = 1 + statusBarHeight = 1 + viewSeparator = "\n" +) + +func joinSections(sections ...string) string { + return strings.Join(sections, viewSeparator) +} func (app AppModel) View() string { - title := titleStyle.Render("DJ — Codex TUI Visualizer") + title := app.header.View() status := app.statusBar.View() if app.helpVisible { - return title + "\n" + app.help.View() + "\n" + status + return joinSections(title, app.help.View(), status) } if app.menuVisible { - return title + "\n" + app.menu.View() + "\n" + status + return joinSections(title, app.menu.View(), 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 joinSections(title, canvas, 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 + return joinSections(title, canvas, divider, panel, status) } 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 e8f047e..7ad7698 100644 --- a/internal/tui/canvas.go +++ b/internal/tui/canvas.go @@ -7,17 +7,36 @@ import ( "github.com/robinojw/dj/internal/state" ) -const canvasColumns = 3 +const ( + canvasColumns = 3 + rowGap = 1 + columnGap = 2 +) type CanvasModel struct { - store *state.ThreadStore - selected 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 +} + func (canvas *CanvasModel) SelectedIndex() int { return canvas.selected } @@ -30,6 +49,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 { @@ -58,11 +85,41 @@ 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 { - return "No active threads. Press 'n' to create one." + return canvas.renderEmpty() + } + + grid := canvas.renderGrid(threads) + 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 { @@ -71,15 +128,48 @@ func (canvas *CanvasModel) View() 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) - cards = append(cards, card.View()) + isPinned := canvas.pinnedIDs[threads[index].ID] + card := NewCardModel(threads[index], isSelected, isPinned) + card.SetSize(cardWidth, cardHeight) + 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) { + missingDimensions := canvas.width == 0 || canvas.height == 0 + if missingDimensions { + return minCardWidth, minCardHeight + } + + 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/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..5603c8f 100644 --- a/internal/tui/card.go +++ b/internal/tui/card.go @@ -8,44 +8,56 @@ import ( ) const ( - cardWidth = 30 - cardHeight = 6 + minCardWidth = 20 + maxCardWidth = 50 + minCardHeight = 4 + maxCardHeight = 12 + cardBorderPadding = 4 + truncateEllipsisLen = 3 ) 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) + 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, + } +} + +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 { @@ -58,12 +70,26 @@ func (card CardModel) View() string { Foreground(statusColor). Render(card.thread.Status) - title := truncate(card.thread.Title, cardWidth-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 := 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) @@ -73,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 3fef7a8..b58ea6b 100644 --- a/internal/tui/card_test.go +++ b/internal/tui/card_test.go @@ -7,39 +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 TestCardPinnedShowsIndicator(testing *testing.T) { + thread := state.NewThreadState(testThreadID, testBuildTitle) + thread.Status = state.StatusActive + + card := NewCardModel(thread, false, true) + card.SetSize(testCardWidth, testCardHeight) + output := card.View() + + 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 new file mode 100644 index 0000000..016ef1a --- /dev/null +++ b/internal/tui/header.go @@ -0,0 +1,56 @@ +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", + "s: select", + "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)) + } + } +}