From bcc4430de1ba061be1ac90b95478451914ac0554 Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 12:36:26 -0400 Subject: [PATCH 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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 06/14] 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", From 589510cb552ecc5e82b9ec22a10e2b7ec6e821ce Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 14:33:47 -0400 Subject: [PATCH 07/14] docs: add session scrollback design --- .../2026-03-18-session-scrollback-design.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/plans/2026-03-18-session-scrollback-design.md diff --git a/docs/plans/2026-03-18-session-scrollback-design.md b/docs/plans/2026-03-18-session-scrollback-design.md new file mode 100644 index 0000000..969d6fd --- /dev/null +++ b/docs/plans/2026-03-18-session-scrollback-design.md @@ -0,0 +1,60 @@ +# Session Scrollback Design + +## Problem + +When a codex CLI session produces output that scrolls past the visible area, the user cannot scroll up to review earlier output. The vt emulator stores a scrollback buffer, but the TUI does not expose it. + +## Approach + +Add scroll state directly to `PTYSession`. The emulator already maintains a scrollback buffer via `Scrollback()`. When the user scrolls up with the mouse wheel, the `Render()` method builds a custom viewport from scrollback lines + visible screen lines instead of calling `emulator.Render()`. + +## Design + +### Scroll state on PTYSession + +Add to `PTYSession`: +- `scrollOffset int` — 0 means at bottom (live), positive means lines scrolled up +- `ScrollUp(lines int)` — increase offset, clamped to max scrollback +- `ScrollDown(lines int)` — decrease offset, clamped to 0 +- `ScrollToBottom()` — reset offset to 0 +- `IsScrolledUp() bool` — returns `scrollOffset > 0` +- `ScrollOffset() int` — returns current offset + +### Custom viewport rendering + +When `scrollOffset > 0`, `Render()` builds output by: +1. Collecting all scrollback lines via `Scrollback().Lines()` +2. Collecting visible screen lines via `CellAt(x, y)` for each row +3. Concatenating into one logical buffer (scrollback on top, screen on bottom) +4. Slicing a window of `emulator.Height()` lines, offset from the bottom by `scrollOffset` +5. Converting cells to styled strings for display + +When `scrollOffset == 0`, `Render()` calls `emulator.Render()` as before. + +### Mouse input + +- Enable mouse mode with `tea.WithMouseCellMotion()` in program options +- Handle `tea.MouseMsg` in `Update()`: + - Scroll wheel up → `ScrollUp` on active PTY session + - Scroll wheel down → `ScrollDown` on active PTY session +- Do not forward scroll wheel events to the PTY process +- Non-scroll mouse events are not forwarded (PTY apps that need mouse input are out of scope) + +### Auto-scroll behavior + +When new output arrives while scrolled up, the view stays in place. The user must scroll down manually or the offset resets on keyboard input to the PTY. + +### Scroll indicator + +When `IsScrolledUp()` is true, render a bottom-line indicator in the session pane: +- Format: `↓ N lines below` +- Styled with a distinct background so it overlays the content visibly +- Disappears when scroll offset returns to 0 + +## Files changed + +- `internal/tui/pty_session.go` — scroll state, modified `Render()` +- `internal/tui/app.go` — mouse message handling in `Update()` +- `internal/tui/app_pty.go` — scroll dispatch for active session +- `internal/tui/app_view.go` — scroll indicator overlay in `renderPTYContent()` +- `cmd/dj/main.go` — add `tea.WithMouseCellMotion()` to program options From f0b53c5cf36a3bba3c8acd98f15f3e8a4f1a2ce0 Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 14:36:59 -0400 Subject: [PATCH 08/14] docs: add session scrollback implementation plan --- .../2026-03-18-session-scrollback-plan.md | 683 ++++++++++++++++++ 1 file changed, 683 insertions(+) create mode 100644 docs/plans/2026-03-18-session-scrollback-plan.md diff --git a/docs/plans/2026-03-18-session-scrollback-plan.md b/docs/plans/2026-03-18-session-scrollback-plan.md new file mode 100644 index 0000000..f8587f7 --- /dev/null +++ b/docs/plans/2026-03-18-session-scrollback-plan.md @@ -0,0 +1,683 @@ +# Session Scrollback Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Enable mouse-wheel scrollback on PTY session panels so users can scroll up through codex CLI output history. + +**Architecture:** Add scroll offset tracking to `PTYSession`, build a custom viewport renderer that combines scrollback buffer lines with visible screen lines, intercept mouse wheel events in Bubble Tea's `Update()` to adjust the offset, and overlay a scroll indicator when scrolled up. + +**Tech Stack:** Go, Bubble Tea (mouse events), charmbracelet/x/vt (scrollback buffer, `uv.Line.Render()`), Lipgloss (indicator styling) + +--- + +### Task 1: Add scroll state to PTYSession + +**Files:** +- Modify: `internal/tui/pty_session.go` +- Test: `internal/tui/pty_session_test.go` + +**Step 1: Write failing tests for scroll state methods** + +Add to `pty_session_test.go`: + +```go +func TestPTYSessionScrollOffset(t *testing.T) { + session := NewPTYSession(PTYSessionConfig{ + ThreadID: "t-1", + Command: "echo", + Args: []string{"test"}, + SendMsg: func(msg PTYOutputMsg) {}, + }) + + if session.ScrollOffset() != 0 { + t.Errorf("expected initial offset 0, got %d", session.ScrollOffset()) + } + + if session.IsScrolledUp() { + t.Error("expected not scrolled up initially") + } +} + +func TestPTYSessionScrollUpDown(t *testing.T) { + session := NewPTYSession(PTYSessionConfig{ + ThreadID: "t-1", + Command: "echo", + Args: []string{"test"}, + SendMsg: func(msg PTYOutputMsg) {}, + }) + + session.ScrollUp(5) + if session.ScrollOffset() != 0 { + t.Errorf("expected offset 0 with no scrollback, got %d", session.ScrollOffset()) + } + + session.ScrollDown(3) + if session.ScrollOffset() != 0 { + t.Errorf("expected offset 0 after scroll down, got %d", session.ScrollOffset()) + } +} + +func TestPTYSessionScrollToBottom(t *testing.T) { + session := NewPTYSession(PTYSessionConfig{ + ThreadID: "t-1", + Command: "echo", + Args: []string{"test"}, + SendMsg: func(msg PTYOutputMsg) {}, + }) + + session.ScrollToBottom() + if session.ScrollOffset() != 0 { + t.Errorf("expected offset 0 after scroll to bottom, got %d", session.ScrollOffset()) + } + if session.IsScrolledUp() { + t.Error("expected not scrolled up after scroll to bottom") + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/tui -run TestPTYSessionScroll -v` +Expected: FAIL — `ScrollOffset`, `IsScrolledUp`, `ScrollUp`, `ScrollDown`, `ScrollToBottom` undefined + +**Step 3: Implement scroll state on PTYSession** + +Add to `pty_session.go`: + +1. Add `scrollOffset int` field to the `PTYSession` struct. + +2. Add these methods: + +```go +const scrollStep = 3 + +func (ps *PTYSession) ScrollUp(lines int) { + ps.mu.Lock() + defer ps.mu.Unlock() + + maxOffset := ps.emulator.ScrollbackLen() + ps.scrollOffset += lines + if ps.scrollOffset > maxOffset { + ps.scrollOffset = maxOffset + } +} + +func (ps *PTYSession) ScrollDown(lines int) { + ps.mu.Lock() + defer ps.mu.Unlock() + + ps.scrollOffset -= lines + if ps.scrollOffset < 0 { + ps.scrollOffset = 0 + } +} + +func (ps *PTYSession) ScrollToBottom() { + ps.mu.Lock() + defer ps.mu.Unlock() + + ps.scrollOffset = 0 +} + +func (ps *PTYSession) ScrollOffset() int { + ps.mu.Lock() + defer ps.mu.Unlock() + + return ps.scrollOffset +} + +func (ps *PTYSession) IsScrolledUp() bool { + ps.mu.Lock() + defer ps.mu.Unlock() + + return ps.scrollOffset > 0 +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/tui -run TestPTYSessionScroll -v` +Expected: PASS + +**Step 5: Commit** + +``` +git add internal/tui/pty_session.go internal/tui/pty_session_test.go +git commit -m "feat: add scroll state tracking to PTYSession" +``` + +--- + +### Task 2: Implement custom viewport rendering when scrolled + +**Files:** +- Create: `internal/tui/pty_scroll.go` +- Test: `internal/tui/pty_scroll_test.go` +- Modify: `internal/tui/pty_session.go` (update `Render()`) + +**Step 1: Write failing test for scrolled rendering** + +Create `internal/tui/pty_scroll_test.go`: + +```go +package tui + +import ( + "strings" + "testing" + + uv "github.com/charmbracelet/ultraviolet" +) + +func TestRenderScrolledViewport(t *testing.T) { + scrollbackLines := []uv.Line{ + uv.NewLine(10), + uv.NewLine(10), + } + screenLines := []string{"visible-1", "visible-2", "visible-3"} + + result := renderScrolledViewport(scrollbackLines, screenLines, 3, 1) + + if len(result) != 3 { + t.Fatalf("expected 3 lines, got %d", len(result)) + } + + if !strings.Contains(result[2], "visible-2") { + t.Errorf("expected visible-2 at bottom, got %q", result[2]) + } +} + +func TestRenderScrolledViewportAtMaxOffset(t *testing.T) { + scrollbackLines := []uv.Line{ + uv.NewLine(10), + uv.NewLine(10), + } + screenLines := []string{"vis-1", "vis-2"} + + result := renderScrolledViewport(scrollbackLines, screenLines, 2, 4) + + if len(result) != 2 { + t.Fatalf("expected 2 lines, got %d", len(result)) + } +} + +func TestRenderScrolledViewportZeroOffset(t *testing.T) { + scrollbackLines := []uv.Line{ + uv.NewLine(10), + } + screenLines := []string{"vis-1", "vis-2"} + + result := renderScrolledViewport(scrollbackLines, screenLines, 2, 0) + + if len(result) != 2 { + t.Fatalf("expected 2 lines, got %d", len(result)) + } + if result[0] != "vis-1" { + t.Errorf("expected vis-1, got %q", result[0]) + } + if result[1] != "vis-2" { + t.Errorf("expected vis-2, got %q", result[1]) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/tui -run TestRenderScrolledViewport -v` +Expected: FAIL — `renderScrolledViewport` undefined + +**Step 3: Implement the scrolled viewport renderer** + +Create `internal/tui/pty_scroll.go`: + +```go +package tui + +import ( + "strings" + + uv "github.com/charmbracelet/ultraviolet" +) + +func renderScrolledViewport( + scrollbackLines []uv.Line, + screenLines []string, + viewportHeight int, + scrollOffset int, +) []string { + allLines := make([]string, 0, len(scrollbackLines)+len(screenLines)) + + for _, line := range scrollbackLines { + allLines = append(allLines, line.Render()) + } + allLines = append(allLines, screenLines...) + + totalLines := len(allLines) + end := totalLines - scrollOffset + if end < 0 { + end = 0 + } + start := end - viewportHeight + if start < 0 { + start = 0 + } + if end > totalLines { + end = totalLines + } + + visible := allLines[start:end] + + for len(visible) < viewportHeight { + visible = append([]string{""}, visible...) + } + + return visible +} + +func renderScrolledOutput( + scrollbackLines []uv.Line, + screenLines []string, + viewportHeight int, + scrollOffset int, +) string { + lines := renderScrolledViewport(scrollbackLines, screenLines, viewportHeight, scrollOffset) + return strings.Join(lines, "\n") +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/tui -run TestRenderScrolledViewport -v` +Expected: PASS + +**Step 5: Update PTYSession.Render() to use scrolled viewport** + +Modify `Render()` in `pty_session.go`: + +```go +func (ps *PTYSession) Render() string { + ps.mu.Lock() + offset := ps.scrollOffset + ps.mu.Unlock() + + if offset == 0 { + return ps.emulator.Render() + } + + scrollback := ps.emulator.Scrollback() + scrollbackLen := scrollback.Len() + scrollbackLines := make([]uv.Line, scrollbackLen) + for i := 0; i < scrollbackLen; i++ { + scrollbackLines[i] = scrollback.Line(i) + } + + screenContent := ps.emulator.Render() + screenLines := strings.Split(screenContent, "\n") + + return renderScrolledOutput( + scrollbackLines, + screenLines, + ps.emulator.Height(), + offset, + ) +} +``` + +Add `"strings"` and `uv "github.com/charmbracelet/ultraviolet"` to imports in `pty_session.go`. + +**Step 6: Run all PTY tests** + +Run: `go test ./internal/tui -run TestPTYSession -v` +Expected: PASS + +**Step 7: Commit** + +``` +git add internal/tui/pty_scroll.go internal/tui/pty_scroll_test.go internal/tui/pty_session.go +git commit -m "feat: custom viewport rendering for scrolled PTY sessions" +``` + +--- + +### Task 3: Enable mouse events and handle scroll wheel + +**Files:** +- Modify: `cmd/dj/main.go` +- Modify: `internal/tui/app.go` +- Modify: `internal/tui/app_pty.go` +- Test: `internal/tui/app_test.go` + +**Step 1: Write failing test for mouse scroll handling** + +Add to `app_test.go`: + +```go +func TestAppMouseScrollUpOnSession(t *testing.T) { + store := state.NewThreadStore() + store.Add("t-1", "Thread 1") + + app := NewAppModel(store, WithInteractiveCommand("cat")) + app.width = 120 + app.height = 40 + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + scrollUp := tea.MouseMsg{ + Button: tea.MouseButtonWheelUp, + Action: tea.MouseActionPress, + } + updated, _ = app.Update(scrollUp) + app = updated.(AppModel) + + ptySession := app.ptySessions["t-1"] + offset := ptySession.ScrollOffset() + if offset < 0 { + t.Errorf("expected non-negative scroll offset, got %d", offset) + } +} + +func TestAppMouseScrollDownOnSession(t *testing.T) { + store := state.NewThreadStore() + store.Add("t-1", "Thread 1") + + app := NewAppModel(store, WithInteractiveCommand("cat")) + app.width = 120 + app.height = 40 + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + ptySession := app.ptySessions["t-1"] + ptySession.ScrollUp(10) + + scrollDown := tea.MouseMsg{ + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + } + updated, _ = app.Update(scrollDown) + app = updated.(AppModel) + + offset := ptySession.ScrollOffset() + expectedMax := 10 - scrollStep + if offset > expectedMax { + t.Errorf("expected offset <= %d after scroll down, got %d", expectedMax, offset) + } +} + +func TestAppMouseScrollIgnoredOnCanvas(t *testing.T) { + store := state.NewThreadStore() + store.Add("t-1", "Thread 1") + + app := NewAppModel(store) + + scrollUp := tea.MouseMsg{ + Button: tea.MouseButtonWheelUp, + Action: tea.MouseActionPress, + } + updated, _ := app.Update(scrollUp) + _ = updated.(AppModel) +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/tui -run TestAppMouseScroll -v` +Expected: FAIL — `tea.MouseMsg` not handled in `Update()` + +**Step 3: Add mouse event handling to Update** + +In `app.go`, add a `tea.MouseMsg` case to `Update()`: + +```go +case tea.MouseMsg: + return app.handleMouse(msg) +``` + +Add the handler method to `app_pty.go`: + +```go +func (app AppModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + isScrollWheel := msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown + if !isScrollWheel { + return app, nil + } + + if app.focusPane != FocusPaneSession { + return app, nil + } + + activeID := app.sessionPanel.ActiveThreadID() + if activeID == "" { + return app, nil + } + + ptySession, exists := app.ptySessions[activeID] + if !exists { + return app, nil + } + + if msg.Button == tea.MouseButtonWheelUp { + ptySession.ScrollUp(scrollStep) + } else { + ptySession.ScrollDown(scrollStep) + } + + return app, nil +} +``` + +**Step 4: Enable mouse in main.go** + +In `cmd/dj/main.go`, change: + +```go +program := tea.NewProgram(app, tea.WithAltScreen()) +``` + +to: + +```go +program := tea.NewProgram(app, tea.WithAltScreen(), tea.WithMouseCellMotion()) +``` + +**Step 5: Run tests to verify they pass** + +Run: `go test ./internal/tui -run TestAppMouseScroll -v` +Expected: PASS + +**Step 6: Run all tests** + +Run: `go test ./internal/tui -v` +Expected: PASS + +**Step 7: Commit** + +``` +git add cmd/dj/main.go internal/tui/app.go internal/tui/app_pty.go internal/tui/app_test.go +git commit -m "feat: mouse wheel scroll for PTY session panels" +``` + +--- + +### Task 4: Add scroll indicator overlay + +**Files:** +- Modify: `internal/tui/app_view.go` +- Test: `internal/tui/app_test.go` + +**Step 1: Write failing test for scroll indicator** + +Add to `app_test.go`: + +```go +func TestAppViewShowsScrollIndicator(t *testing.T) { + store := state.NewThreadStore() + store.Add("t-1", "Thread 1") + + app := NewAppModel(store, WithInteractiveCommand("cat")) + app.width = 80 + app.height = 30 + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + ptySession := app.ptySessions["t-1"] + ptySession.ScrollUp(5) + + view := app.View() + hasIndicator := strings.Contains(view, "↓") || strings.Contains(view, "lines below") + if !hasIndicator { + t.Error("expected scroll indicator when scrolled up") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui -run TestAppViewShowsScrollIndicator -v` +Expected: FAIL — no indicator in view output + +**Step 3: Add scroll indicator to renderPTYContent** + +In `app_view.go`, modify `renderPTYContent`: + +```go +const scrollIndicatorStyle = "240" + +func (app AppModel) renderPTYContent(threadID string) string { + ptySession, exists := app.ptySessions[threadID] + if !exists { + return "" + } + + content := ptySession.Render() + hasVisibleContent := strings.TrimSpace(content) != "" + if !hasVisibleContent && !ptySession.Running() { + return fmt.Sprintf("[process exited: %d]", ptySession.ExitCode()) + } + + if ptySession.IsScrolledUp() { + indicator := renderScrollIndicator(ptySession.ScrollOffset()) + lines := strings.Split(content, "\n") + if len(lines) > 0 { + lines[len(lines)-1] = indicator + } + content = strings.Join(lines, "\n") + } + + return content +} + +func renderScrollIndicator(linesBelow int) string { + text := fmt.Sprintf(" ↓ %d lines below ", linesBelow) + style := lipgloss.NewStyle(). + Background(lipgloss.Color(scrollIndicatorStyle)). + Foreground(lipgloss.Color("255")) + return style.Render(text) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui -run TestAppViewShowsScrollIndicator -v` +Expected: PASS + +**Step 5: Run all tests** + +Run: `go test ./internal/tui -v` +Expected: PASS + +**Step 6: Commit** + +``` +git add internal/tui/app_view.go internal/tui/app_test.go +git commit -m "feat: scroll indicator overlay when session is scrolled up" +``` + +--- + +### Task 5: Update help screen with scroll keybinding + +**Files:** +- Modify: `internal/tui/help.go` +- Test: `internal/tui/app_test.go` + +**Step 1: Write failing test** + +Add to `app_test.go`: + +```go +func TestHelpShowsScrollKeybinding(t *testing.T) { + help := NewHelpModel() + view := help.View() + if !strings.Contains(view, "Scroll") { + t.Error("expected Scroll keybinding in help") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui -run TestHelpShowsScrollKeybinding -v` +Expected: FAIL + +**Step 3: Add scroll entry to help** + +Read `internal/tui/help.go` and add a line for mouse scroll in the keybindings list. The exact format depends on the existing help entries — match their pattern. Add something like: + +``` +"Mouse Wheel Scroll session up/down" +``` + +in the session section of the help text. + +**Step 4: Run test to verify it passes** + +Run: `go test ./internal/tui -run TestHelpShowsScrollKeybinding -v` +Expected: PASS + +**Step 5: Commit** + +``` +git add internal/tui/help.go internal/tui/app_test.go +git commit -m "feat: add scroll keybinding to help screen" +``` + +--- + +### Task 6: Lint and full test pass + +**Files:** All modified files + +**Step 1: Run linter** + +Run: `golangci-lint run` +Expected: No errors. If there are funlen violations (60 line max), extract helper functions. + +**Step 2: Run all tests with race detector** + +Run: `go test ./... -v -race` +Expected: PASS + +**Step 3: Run build** + +Run: `go build -o dj ./cmd/dj` +Expected: Build succeeds + +**Step 4: Fix any issues found** + +Address lint/race/build errors if any. + +**Step 5: Commit fixes if needed** + +``` +git add -A +git commit -m "fix: lint and race detector issues" +``` From 7affd3c4e40e9415cec1c115f63e202ed1b6ce9b Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 14:45:23 -0400 Subject: [PATCH 09/14] feat: add scroll state tracking to PTYSession --- internal/tui/pty_session.go | 96 +++++++++++---- internal/tui/pty_session_test.go | 193 +++++++++++++++++++++---------- 2 files changed, 207 insertions(+), 82 deletions(-) diff --git a/internal/tui/pty_session.go b/internal/tui/pty_session.go index 8303d4e..046699b 100644 --- a/internal/tui/pty_session.go +++ b/internal/tui/pty_session.go @@ -16,6 +16,7 @@ const ( defaultTermRows = 24 ptyReadBufSize = 4096 ptyTermEnvVar = "TERM=xterm-256color" + scrollStep = 3 ) type PTYSessionConfig struct { @@ -26,16 +27,17 @@ type PTYSessionConfig struct { } type PTYSession struct { - threadID string - command string - args []string - cmd *exec.Cmd - ptmx *os.File - emulator *vt.SafeEmulator - mu sync.Mutex - running bool - exitCode int - sendMsg func(PTYOutputMsg) + threadID string + command string + args []string + cmd *exec.Cmd + ptmx *os.File + emulator *vt.SafeEmulator + mu sync.Mutex + running bool + exitCode int + scrollOffset int + sendMsg func(PTYOutputMsg) } func NewPTYSession(config PTYSessionConfig) *PTYSession { @@ -81,9 +83,9 @@ func (ps *PTYSession) Start() error { func (ps *PTYSession) readLoop() { buf := make([]byte, ptyReadBufSize) for { - n, err := ps.ptmx.Read(buf) - if n > 0 { - ps.emulator.Write(buf[:n]) + bytesRead, err := ps.ptmx.Read(buf) + if bytesRead > 0 { + ps.emulator.Write(buf[:bytesRead]) ps.sendMsg(PTYOutputMsg{ThreadID: ps.threadID}) } if err != nil { @@ -104,15 +106,9 @@ func (ps *PTYSession) readLoop() { func (ps *PTYSession) responseLoop() { buf := make([]byte, ptyReadBufSize) for { - n, err := ps.emulator.Read(buf) - if n > 0 { - ps.mu.Lock() - ptmx := ps.ptmx - ps.mu.Unlock() - - if ptmx != nil { - ptmx.Write(buf[:n]) - } + bytesRead, err := ps.emulator.Read(buf) + if bytesRead > 0 { + ps.writeResponseToPTY(buf[:bytesRead]) } if err != nil { return @@ -120,6 +116,17 @@ func (ps *PTYSession) responseLoop() { } } +func (ps *PTYSession) writeResponseToPTY(data []byte) { + ps.mu.Lock() + ptmx := ps.ptmx + ps.mu.Unlock() + + if ptmx == nil { + return + } + ptmx.Write(data) +} + func (ps *PTYSession) WriteBytes(data []byte) error { ps.mu.Lock() isRunning := ps.running @@ -165,7 +172,8 @@ func (ps *PTYSession) Stop() { ps.ptmx.Close() } - if ps.cmd != nil && ps.cmd.Process != nil { + hasProcess := ps.cmd != nil && ps.cmd.Process != nil + if hasProcess { ps.cmd.Process.Kill() ps.cmd.Wait() } @@ -189,6 +197,48 @@ func (ps *PTYSession) ThreadID() string { return ps.threadID } +func (ps *PTYSession) ScrollUp(lines int) { + ps.mu.Lock() + defer ps.mu.Unlock() + + maxOffset := ps.emulator.ScrollbackLen() + ps.scrollOffset += lines + if ps.scrollOffset > maxOffset { + ps.scrollOffset = maxOffset + } +} + +func (ps *PTYSession) ScrollDown(lines int) { + ps.mu.Lock() + defer ps.mu.Unlock() + + ps.scrollOffset -= lines + if ps.scrollOffset < 0 { + ps.scrollOffset = 0 + } +} + +func (ps *PTYSession) ScrollToBottom() { + ps.mu.Lock() + defer ps.mu.Unlock() + + ps.scrollOffset = 0 +} + +func (ps *PTYSession) ScrollOffset() int { + ps.mu.Lock() + defer ps.mu.Unlock() + + return ps.scrollOffset +} + +func (ps *PTYSession) IsScrolledUp() bool { + ps.mu.Lock() + defer ps.mu.Unlock() + + return ps.scrollOffset > 0 +} + var _ io.Writer = (*PTYSession)(nil) func (ps *PTYSession) Write(data []byte) (int, error) { diff --git a/internal/tui/pty_session_test.go b/internal/tui/pty_session_test.go index b0f5d49..b4779d8 100644 --- a/internal/tui/pty_session_test.go +++ b/internal/tui/pty_session_test.go @@ -7,16 +7,33 @@ import ( "time" ) -const ptyTestTimeout = 5 * time.Second +const ( + ptyTestTimeout = 5 * time.Second + ptyPollInterval = 10 * time.Millisecond + ptyExitChannelBuffer = 10 + ptyResizeWidth = 120 + ptyResizeHeight = 40 + ptyScrollUpAmount = 5 + ptyScrollDownAmount = 3 + + ptyTestThreadID = "t-1" + testCmdEcho = "echo" + testCmdCat = "cat" + testArgHelloPty = "hello pty" + testArgTest = "test" + testStartFailed = "start failed: %v" +) + +func noopSendMsg(msg PTYOutputMsg) {} -func TestPTYSessionStartAndRender(t *testing.T) { +func TestPTYSessionStartAndRender(testing *testing.T) { var mu sync.Mutex var messages []PTYOutputMsg session := NewPTYSession(PTYSessionConfig{ - ThreadID: "t-1", - Command: "echo", - Args: []string{"hello pty"}, + ThreadID: ptyTestThreadID, + Command: testCmdEcho, + Args: []string{testArgHelloPty}, SendMsg: func(msg PTYOutputMsg) { mu.Lock() messages = append(messages, msg) @@ -25,20 +42,12 @@ func TestPTYSessionStartAndRender(t *testing.T) { }) if err := session.Start(); err != nil { - t.Fatalf("start failed: %v", err) + testing.Fatalf(testStartFailed, err) } deadline := time.After(ptyTestTimeout) for { - mu.Lock() - hasExited := false - for _, msg := range messages { - if msg.Exited { - hasExited = true - break - } - } - mu.Unlock() + hasExited := checkForExit(&mu, messages) if hasExited { break @@ -46,29 +55,41 @@ func TestPTYSessionStartAndRender(t *testing.T) { select { case <-deadline: - t.Fatal("timed out waiting for process exit") + testing.Fatal("timed out waiting for process exit") default: - time.Sleep(10 * time.Millisecond) + time.Sleep(ptyPollInterval) } } rendered := session.Render() - if !strings.Contains(rendered, "hello pty") { - t.Errorf("expected 'hello pty' in render output, got %q", rendered) + if !strings.Contains(rendered, testArgHelloPty) { + testing.Errorf("expected 'hello pty' in render output, got %q", rendered) } if session.Running() { - t.Error("expected session to not be running after exit") + testing.Error("expected session to not be running after exit") + } +} + +func checkForExit(mu *sync.Mutex, messages []PTYOutputMsg) bool { + mu.Lock() + defer mu.Unlock() + + for _, msg := range messages { + if msg.Exited { + return true + } } + return false } -func TestPTYSessionWriteBytes(t *testing.T) { +func TestPTYSessionWriteBytes(testing *testing.T) { var mu sync.Mutex var gotOutput bool session := NewPTYSession(PTYSessionConfig{ - ThreadID: "t-1", - Command: "cat", + ThreadID: ptyTestThreadID, + Command: testCmdCat, SendMsg: func(msg PTYOutputMsg) { mu.Lock() if !msg.Exited { @@ -79,13 +100,13 @@ func TestPTYSessionWriteBytes(t *testing.T) { }) if err := session.Start(); err != nil { - t.Fatalf("start failed: %v", err) + testing.Fatalf(testStartFailed, err) } defer session.Stop() err := session.WriteBytes([]byte("test input\n")) if err != nil { - t.Fatalf("write failed: %v", err) + testing.Fatalf("write failed: %v", err) } deadline := time.After(ptyTestTimeout) @@ -100,41 +121,41 @@ func TestPTYSessionWriteBytes(t *testing.T) { select { case <-deadline: - t.Fatal("timed out waiting for cat output") + testing.Fatal("timed out waiting for cat output") default: - time.Sleep(10 * time.Millisecond) + time.Sleep(ptyPollInterval) } } rendered := session.Render() if !strings.Contains(rendered, "test input") { - t.Errorf("expected 'test input' in render output, got %q", rendered) + testing.Errorf("expected 'test input' in render output, got %q", rendered) } } -func TestPTYSessionResize(t *testing.T) { +func TestPTYSessionResize(testing *testing.T) { session := NewPTYSession(PTYSessionConfig{ - ThreadID: "t-1", - Command: "echo", + ThreadID: ptyTestThreadID, + Command: testCmdEcho, Args: []string{"resize test"}, - SendMsg: func(msg PTYOutputMsg) {}, + SendMsg: noopSendMsg, }) - session.Resize(120, 40) + session.Resize(ptyResizeWidth, ptyResizeHeight) - if session.emulator.Width() != 120 { - t.Errorf("expected width 120, got %d", session.emulator.Width()) + if session.emulator.Width() != ptyResizeWidth { + testing.Errorf("expected width %d, got %d", ptyResizeWidth, session.emulator.Width()) } - if session.emulator.Height() != 40 { - t.Errorf("expected height 40, got %d", session.emulator.Height()) + if session.emulator.Height() != ptyResizeHeight { + testing.Errorf("expected height %d, got %d", ptyResizeHeight, session.emulator.Height()) } } -func TestPTYSessionExitCallback(t *testing.T) { - exitCh := make(chan PTYOutputMsg, 10) +func TestPTYSessionExitCallback(testing *testing.T) { + exitCh := make(chan PTYOutputMsg, ptyExitChannelBuffer) session := NewPTYSession(PTYSessionConfig{ - ThreadID: "t-1", + ThreadID: ptyTestThreadID, Command: "true", SendMsg: func(msg PTYOutputMsg) { exitCh <- msg @@ -142,63 +163,117 @@ func TestPTYSessionExitCallback(t *testing.T) { }) if err := session.Start(); err != nil { - t.Fatalf("start failed: %v", err) + testing.Fatalf(testStartFailed, err) } deadline := time.After(ptyTestTimeout) for { select { case msg := <-exitCh: - if msg.Exited { - if msg.ThreadID != "t-1" { - t.Errorf("expected thread ID t-1, got %s", msg.ThreadID) - } - return + if !msg.Exited { + continue } + if msg.ThreadID != ptyTestThreadID { + testing.Errorf("expected thread ID %s, got %s", ptyTestThreadID, msg.ThreadID) + } + return case <-deadline: - t.Fatal("timed out waiting for exit callback") + testing.Fatal("timed out waiting for exit callback") } } } -func TestPTYSessionWriteAfterStop(t *testing.T) { +func TestPTYSessionWriteAfterStop(testing *testing.T) { session := NewPTYSession(PTYSessionConfig{ - ThreadID: "t-1", - Command: "cat", - SendMsg: func(msg PTYOutputMsg) {}, + ThreadID: ptyTestThreadID, + Command: testCmdCat, + SendMsg: noopSendMsg, }) if err := session.Start(); err != nil { - t.Fatalf("start failed: %v", err) + testing.Fatalf(testStartFailed, err) } session.Stop() err := session.WriteBytes([]byte("should fail")) if err == nil { - t.Error("expected error writing after stop") + testing.Error("expected error writing after stop") } } -func TestPTYSessionStop(t *testing.T) { +func TestPTYSessionStop(testing *testing.T) { session := NewPTYSession(PTYSessionConfig{ - ThreadID: "t-1", + ThreadID: ptyTestThreadID, Command: "sleep", Args: []string{"60"}, - SendMsg: func(msg PTYOutputMsg) {}, + SendMsg: noopSendMsg, }) if err := session.Start(); err != nil { - t.Fatalf("start failed: %v", err) + testing.Fatalf(testStartFailed, err) } if !session.Running() { - t.Error("expected session to be running") + testing.Error("expected session to be running") } session.Stop() if session.Running() { - t.Error("expected session to be stopped") + testing.Error("expected session to be stopped") + } +} + +func TestPTYSessionScrollOffset(testing *testing.T) { + session := NewPTYSession(PTYSessionConfig{ + ThreadID: ptyTestThreadID, + Command: testCmdEcho, + Args: []string{testArgTest}, + SendMsg: noopSendMsg, + }) + + if session.ScrollOffset() != 0 { + testing.Errorf("expected initial offset 0, got %d", session.ScrollOffset()) + } + + if session.IsScrolledUp() { + testing.Error("expected not scrolled up initially") + } +} + +func TestPTYSessionScrollUpDown(testing *testing.T) { + session := NewPTYSession(PTYSessionConfig{ + ThreadID: ptyTestThreadID, + Command: testCmdEcho, + Args: []string{testArgTest}, + SendMsg: noopSendMsg, + }) + + session.ScrollUp(ptyScrollUpAmount) + if session.ScrollOffset() != 0 { + testing.Errorf("expected offset 0 with no scrollback, got %d", session.ScrollOffset()) + } + + session.ScrollDown(ptyScrollDownAmount) + if session.ScrollOffset() != 0 { + testing.Errorf("expected offset 0 after scroll down, got %d", session.ScrollOffset()) + } +} + +func TestPTYSessionScrollToBottom(testing *testing.T) { + session := NewPTYSession(PTYSessionConfig{ + ThreadID: ptyTestThreadID, + Command: testCmdEcho, + Args: []string{testArgTest}, + SendMsg: noopSendMsg, + }) + + session.ScrollToBottom() + if session.ScrollOffset() != 0 { + testing.Errorf("expected offset 0 after scroll to bottom, got %d", session.ScrollOffset()) + } + if session.IsScrolledUp() { + testing.Error("expected not scrolled up after scroll to bottom") } } From c8073578d18107f6d9e5520d590cc37f61c77167 Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 14:47:05 -0400 Subject: [PATCH 10/14] feat: custom viewport rendering for scrolled PTY sessions --- internal/tui/pty_scroll.go | 52 +++++++++++++++++++++++ internal/tui/pty_scroll_test.go | 73 +++++++++++++++++++++++++++++++++ internal/tui/pty_session.go | 31 +++++++++++++- 3 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 internal/tui/pty_scroll.go create mode 100644 internal/tui/pty_scroll_test.go diff --git a/internal/tui/pty_scroll.go b/internal/tui/pty_scroll.go new file mode 100644 index 0000000..3e6e1b4 --- /dev/null +++ b/internal/tui/pty_scroll.go @@ -0,0 +1,52 @@ +package tui + +import ( + "strings" + + uv "github.com/charmbracelet/ultraviolet" +) + +func renderScrolledViewport( + scrollbackLines []uv.Line, + screenLines []string, + viewportHeight int, + scrollOffset int, +) []string { + allLines := make([]string, 0, len(scrollbackLines)+len(screenLines)) + + for _, line := range scrollbackLines { + allLines = append(allLines, line.Render()) + } + allLines = append(allLines, screenLines...) + + totalLines := len(allLines) + end := totalLines - scrollOffset + if end < 0 { + end = 0 + } + start := end - viewportHeight + if start < 0 { + start = 0 + } + if end > totalLines { + end = totalLines + } + + visible := allLines[start:end] + + for len(visible) < viewportHeight { + visible = append([]string{""}, visible...) + } + + return visible +} + +func renderScrolledOutput( + scrollbackLines []uv.Line, + screenLines []string, + viewportHeight int, + scrollOffset int, +) string { + lines := renderScrolledViewport(scrollbackLines, screenLines, viewportHeight, scrollOffset) + return strings.Join(lines, "\n") +} diff --git a/internal/tui/pty_scroll_test.go b/internal/tui/pty_scroll_test.go new file mode 100644 index 0000000..e7fe236 --- /dev/null +++ b/internal/tui/pty_scroll_test.go @@ -0,0 +1,73 @@ +package tui + +import ( + "strings" + "testing" + + uv "github.com/charmbracelet/ultraviolet" +) + +const ( + scrollTestLineWidth = 10 + scrollTestViewportThree = 3 + scrollTestViewportTwo = 2 + scrollTestOffsetOne = 1 + scrollTestOffsetFour = 4 + + scrollTestVisible2 = "visible-2" + scrollTestVis1 = "vis-1" + scrollTestVis2 = "vis-2" + scrollTestExpectFmt = "expected %d lines, got %d" + scrollTestExpectStrFmt = "expected %s, got %q" +) + +func TestRenderScrolledViewport(testing *testing.T) { + scrollbackLines := []uv.Line{ + uv.NewLine(scrollTestLineWidth), + uv.NewLine(scrollTestLineWidth), + } + screenLines := []string{"visible-1", scrollTestVisible2, "visible-3"} + + result := renderScrolledViewport(scrollbackLines, screenLines, scrollTestViewportThree, scrollTestOffsetOne) + + if len(result) != scrollTestViewportThree { + testing.Fatalf(scrollTestExpectFmt, scrollTestViewportThree, len(result)) + } + + if !strings.Contains(result[scrollTestViewportThree-1], scrollTestVisible2) { + testing.Errorf("expected %s at bottom, got %q", scrollTestVisible2, result[scrollTestViewportThree-1]) + } +} + +func TestRenderScrolledViewportAtMaxOffset(testing *testing.T) { + scrollbackLines := []uv.Line{ + uv.NewLine(scrollTestLineWidth), + uv.NewLine(scrollTestLineWidth), + } + screenLines := []string{scrollTestVis1, scrollTestVis2} + + result := renderScrolledViewport(scrollbackLines, screenLines, scrollTestViewportTwo, scrollTestOffsetFour) + + if len(result) != scrollTestViewportTwo { + testing.Fatalf(scrollTestExpectFmt, scrollTestViewportTwo, len(result)) + } +} + +func TestRenderScrolledViewportZeroOffset(testing *testing.T) { + scrollbackLines := []uv.Line{ + uv.NewLine(scrollTestLineWidth), + } + screenLines := []string{scrollTestVis1, scrollTestVis2} + + result := renderScrolledViewport(scrollbackLines, screenLines, scrollTestViewportTwo, 0) + + if len(result) != scrollTestViewportTwo { + testing.Fatalf(scrollTestExpectFmt, scrollTestViewportTwo, len(result)) + } + if result[0] != scrollTestVis1 { + testing.Errorf(scrollTestExpectStrFmt, scrollTestVis1, result[0]) + } + if result[1] != scrollTestVis2 { + testing.Errorf(scrollTestExpectStrFmt, scrollTestVis2, result[1]) + } +} diff --git a/internal/tui/pty_session.go b/internal/tui/pty_session.go index 046699b..98fb976 100644 --- a/internal/tui/pty_session.go +++ b/internal/tui/pty_session.go @@ -5,8 +5,10 @@ import ( "io" "os" "os/exec" + "strings" "sync" + uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/vt" "github.com/creack/pty" ) @@ -161,7 +163,34 @@ func (ps *PTYSession) Resize(width int, height int) { } func (ps *PTYSession) Render() string { - return ps.emulator.Render() + ps.mu.Lock() + offset := ps.scrollOffset + ps.mu.Unlock() + + if offset == 0 { + return ps.emulator.Render() + } + + return ps.renderScrolled(offset) +} + +func (ps *PTYSession) renderScrolled(offset int) string { + scrollback := ps.emulator.Scrollback() + scrollbackLen := scrollback.Len() + scrollbackLines := make([]uv.Line, scrollbackLen) + for index := 0; index < scrollbackLen; index++ { + scrollbackLines[index] = scrollback.Line(index) + } + + screenContent := ps.emulator.Render() + screenLines := strings.Split(screenContent, "\n") + + return renderScrolledOutput( + scrollbackLines, + screenLines, + ps.emulator.Height(), + offset, + ) } func (ps *PTYSession) Stop() { From d6fefefe62cf9eeb3159de14aaac5cf799178758 Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 14:49:11 -0400 Subject: [PATCH 11/14] feat: mouse wheel scroll for PTY session panels --- cmd/dj/main.go | 2 +- internal/tui/app.go | 2 + internal/tui/app_mouse_test.go | 86 +++++++++++++++++++++++++++++ internal/tui/app_pty.go | 98 +++++++++++++++++++++++++--------- 4 files changed, 163 insertions(+), 25 deletions(-) create mode 100644 internal/tui/app_mouse_test.go diff --git a/cmd/dj/main.go b/cmd/dj/main.go index 17039cb..89d58e7 100644 --- a/cmd/dj/main.go +++ b/cmd/dj/main.go @@ -48,7 +48,7 @@ func runApp(cmd *cobra.Command, args []string) error { tui.WithInteractiveCommand(cfg.Interactive.Command, cfg.Interactive.Args...), ) - program := tea.NewProgram(app, tea.WithAltScreen()) + program := tea.NewProgram(app, tea.WithAltScreen(), tea.WithMouseCellMotion()) finalModel, err := program.Run() if finalApp, ok := finalModel.(tui.AppModel); ok { diff --git a/internal/tui/app.go b/internal/tui/app.go index 0c1f8e7..1f3a97e 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -105,6 +105,8 @@ func (app AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return app.handleKey(msg) + case tea.MouseMsg: + return app.handleMouse(msg) case tea.WindowSizeMsg: return app.handleWindowSize(msg) case protoEventMsg: diff --git a/internal/tui/app_mouse_test.go b/internal/tui/app_mouse_test.go new file mode 100644 index 0000000..3f06af3 --- /dev/null +++ b/internal/tui/app_mouse_test.go @@ -0,0 +1,86 @@ +package tui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/state" +) + +const ( + mouseTestWidth = 120 + mouseTestHeight = 40 + mouseTestThread = "t-1" + mouseTestTitle = "Thread 1" + mouseTestCmd = "cat" +) + +func TestAppMouseScrollUpOnSession(testing *testing.T) { + store := state.NewThreadStore() + store.Add(mouseTestThread, mouseTestTitle) + + app := NewAppModel(store, WithInteractiveCommand(mouseTestCmd)) + app.width = mouseTestWidth + app.height = mouseTestHeight + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + scrollUp := tea.MouseMsg{ + Button: tea.MouseButtonWheelUp, + Action: tea.MouseActionPress, + } + updated, _ = app.Update(scrollUp) + app = updated.(AppModel) + + ptySession := app.ptySessions[mouseTestThread] + offset := ptySession.ScrollOffset() + if offset < 0 { + testing.Errorf("expected non-negative scroll offset, got %d", offset) + } +} + +func TestAppMouseScrollDownOnSession(testing *testing.T) { + store := state.NewThreadStore() + store.Add(mouseTestThread, mouseTestTitle) + + app := NewAppModel(store, WithInteractiveCommand(mouseTestCmd)) + app.width = mouseTestWidth + app.height = mouseTestHeight + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + ptySession := app.ptySessions[mouseTestThread] + ptySession.ScrollUp(scrollStep) + + scrollDown := tea.MouseMsg{ + Button: tea.MouseButtonWheelDown, + Action: tea.MouseActionPress, + } + updated, _ = app.Update(scrollDown) + app = updated.(AppModel) + + offset := ptySession.ScrollOffset() + if offset != 0 { + testing.Errorf("expected offset 0 after scroll down, got %d", offset) + } +} + +func TestAppMouseScrollIgnoredOnCanvas(testing *testing.T) { + store := state.NewThreadStore() + store.Add(mouseTestThread, mouseTestTitle) + + app := NewAppModel(store) + + scrollUp := tea.MouseMsg{ + Button: tea.MouseButtonWheelUp, + Action: tea.MouseActionPress, + } + updated, _ := app.Update(scrollUp) + _ = updated.(AppModel) +} diff --git a/internal/tui/app_pty.go b/internal/tui/app_pty.go index cc1bede..ab04c85 100644 --- a/internal/tui/app_pty.go +++ b/internal/tui/app_pty.go @@ -2,6 +2,35 @@ package tui import tea "github.com/charmbracelet/bubbletea" +func (app AppModel) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + isScrollWheel := msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown + if !isScrollWheel { + return app, nil + } + + if app.focusPane != FocusPaneSession { + return app, nil + } + + activeID := app.sessionPanel.ActiveThreadID() + if activeID == "" { + return app, nil + } + + ptySession, exists := app.ptySessions[activeID] + if !exists { + return app, nil + } + + if msg.Button == tea.MouseButtonWheelUp { + ptySession.ScrollUp(scrollStep) + } else { + ptySession.ScrollDown(scrollStep) + } + + return app, nil +} + func (app AppModel) handleSessionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyCtrlC: @@ -70,25 +99,31 @@ func (app AppModel) pinSession(threadID string) (tea.Model, tea.Cmd) { return app, nil } - _, hasPTY := app.ptySessions[threadID] - if !hasPTY { - ptySession := NewPTYSession(PTYSessionConfig{ - ThreadID: threadID, - Command: app.resolveInteractiveCmd(), - Args: app.interactiveArgs, - SendMsg: app.ptyEventCallback(), - }) - if err := ptySession.Start(); err != nil { - app.statusBar.SetError(err.Error()) - return app, nil - } - app.ptySessions[threadID] = ptySession - } + app.ensurePTYSession(threadID) app.sessionPanel.Pin(threadID) return app, app.rebalancePTYSizes() } +func (app *AppModel) ensurePTYSession(threadID string) { + _, hasPTY := app.ptySessions[threadID] + if hasPTY { + return + } + + ptySession := NewPTYSession(PTYSessionConfig{ + ThreadID: threadID, + Command: app.resolveInteractiveCmd(), + Args: app.interactiveArgs, + SendMsg: app.ptyEventCallback(), + }) + if err := ptySession.Start(); err != nil { + app.statusBar.SetError(err.Error()) + return + } + app.ptySessions[threadID] = ptySession +} + func (app AppModel) pinnedIndex(threadID string) int { for index, pinned := range app.sessionPanel.PinnedSessions() { if pinned == threadID { @@ -141,6 +176,18 @@ func (app AppModel) rebalancePTYSizes() tea.Cmd { return nil } + contentWidth, contentHeight := app.panelContentDimensions() + + if app.sessionPanel.Zoomed() { + app.resizeZoomedSession(contentWidth, contentHeight) + return nil + } + + app.resizeSplitSessions(pinned, contentWidth, contentHeight) + return nil +} + +func (app AppModel) panelContentDimensions() (int, int) { canvasHeight := int(float64(app.height) * app.sessionPanel.SplitRatio()) panelHeight := app.height - canvasHeight - dividerHeight if panelHeight < 1 { @@ -155,16 +202,19 @@ func (app AppModel) rebalancePTYSizes() tea.Cmd { if contentHeight < 1 { contentHeight = 1 } + return contentWidth, contentHeight +} - if app.sessionPanel.Zoomed() { - activeID := app.sessionPanel.ActiveThreadID() - ptySession, exists := app.ptySessions[activeID] - if exists { - ptySession.Resize(contentWidth, contentHeight) - } - return nil +func (app AppModel) resizeZoomedSession(contentWidth int, contentHeight int) { + activeID := app.sessionPanel.ActiveThreadID() + ptySession, exists := app.ptySessions[activeID] + if !exists { + return } + ptySession.Resize(contentWidth, contentHeight) +} +func (app AppModel) resizeSplitSessions(pinned []string, contentWidth int, contentHeight int) { count := len(pinned) paneWidth := app.width/count - sessionPaneBorderSize if paneWidth < 1 { @@ -172,11 +222,11 @@ func (app AppModel) rebalancePTYSizes() tea.Cmd { } for _, threadID := range pinned { ptySession, exists := app.ptySessions[threadID] - if exists { - ptySession.Resize(paneWidth, contentHeight) + if !exists { + continue } + ptySession.Resize(paneWidth, contentHeight) } - return nil } func (app *AppModel) StopAllPTYSessions() { From ac45b80298448512a50e42e4312704876d8193e8 Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 14:52:45 -0400 Subject: [PATCH 12/14] feat: scroll indicator overlay when session is scrolled up --- internal/tui/app_scroll_indicator_test.go | 52 +++++++++++++++++++++++ internal/tui/app_view.go | 37 ++++++++++++---- 2 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 internal/tui/app_scroll_indicator_test.go diff --git a/internal/tui/app_scroll_indicator_test.go b/internal/tui/app_scroll_indicator_test.go new file mode 100644 index 0000000..8c163c5 --- /dev/null +++ b/internal/tui/app_scroll_indicator_test.go @@ -0,0 +1,52 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/state" +) + +const ( + indicatorTestWidth = 80 + indicatorTestHeight = 30 + indicatorScrollUp = 5 + indicatorTestThread = "t-1" + indicatorScrollbackLines = 30 +) + +func TestAppViewShowsScrollIndicator(testing *testing.T) { + store := state.NewThreadStore() + store.Add(indicatorTestThread, "Thread 1") + + app := NewAppModel(store, WithInteractiveCommand("cat")) + app.width = indicatorTestWidth + app.height = indicatorTestHeight + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + ptySession := app.ptySessions[indicatorTestThread] + fillScrollback(ptySession) + + ptySession.ScrollUp(indicatorScrollUp) + + view := app.View() + hasDownArrow := strings.Contains(view, "↓") + hasLinesBelow := strings.Contains(view, "lines below") + hasIndicator := hasDownArrow || hasLinesBelow + if !hasIndicator { + testing.Error("expected scroll indicator when scrolled up") + } +} + +func fillScrollback(session *PTYSession) { + for index := 0; index < indicatorScrollbackLines; index++ { + line := fmt.Sprintf("scrollback line %d\r\n", index) + session.emulator.Write([]byte(line)) + } +} diff --git a/internal/tui/app_view.go b/internal/tui/app_view.go index 2d279fd..f077176 100644 --- a/internal/tui/app_view.go +++ b/internal/tui/app_view.go @@ -8,9 +8,11 @@ import ( ) const ( - headerHeight = 1 - statusBarHeight = 1 - viewSeparator = "\n" + headerHeight = 1 + statusBarHeight = 1 + viewSeparator = "\n" + colorDimGray = "240" + scrollIndicatorFg = "255" ) func joinSections(sections ...string) string { @@ -130,18 +132,37 @@ func (app AppModel) renderPTYContent(threadID string) string { content := ptySession.Render() hasVisibleContent := strings.TrimSpace(content) != "" - if hasVisibleContent { - return content + isEmptyAndExited := !hasVisibleContent && !ptySession.Running() + if isEmptyAndExited { + return fmt.Sprintf("[process exited: %d]", ptySession.ExitCode()) } - if !ptySession.Running() { - return fmt.Sprintf("[process exited: %d]", ptySession.ExitCode()) + if ptySession.IsScrolledUp() { + content = overlayScrollIndicator(content, ptySession.ScrollOffset()) } + return content } +func overlayScrollIndicator(content string, linesBelow int) string { + indicator := renderScrollIndicator(linesBelow) + lines := strings.Split(content, viewSeparator) + if len(lines) > 0 { + lines[len(lines)-1] = indicator + } + return strings.Join(lines, viewSeparator) +} + +func renderScrollIndicator(linesBelow int) string { + text := fmt.Sprintf(" ↓ %d lines below ", linesBelow) + style := lipgloss.NewStyle(). + Background(lipgloss.Color(colorDimGray)). + Foreground(lipgloss.Color(scrollIndicatorFg)) + return style.Render(text) +} + func (app AppModel) sessionPaneStyle(width int, height int, active bool) lipgloss.Style { - borderColor := lipgloss.Color("240") + borderColor := lipgloss.Color(colorDimGray) if active { borderColor = lipgloss.Color("39") } From 7d9f525258e03e1bc264b9f0e870d2c90ce867f2 Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 14:53:34 -0400 Subject: [PATCH 13/14] feat: add scroll keybinding to help screen --- internal/tui/help.go | 16 +++++++++++----- internal/tui/help_scroll_test.go | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 internal/tui/help_scroll_test.go diff --git a/internal/tui/help.go b/internal/tui/help.go index 5c3244e..a57c0b3 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -7,16 +7,21 @@ import ( "github.com/charmbracelet/lipgloss" ) -const helpKeyColumnWidth = 12 +const ( + helpKeyColumnWidth = 12 + helpAccentColor = "39" + helpPaddingH = 2 + helpNewline = "\n" +) var ( helpBorderStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("39")). - Padding(1, 2) + BorderForeground(lipgloss.Color(helpAccentColor)). + Padding(1, helpPaddingH) helpTitleStyle = lipgloss.NewStyle(). Bold(true). - Foreground(lipgloss.Color("39")). + Foreground(lipgloss.Color(helpAccentColor)). MarginBottom(1) helpKeyStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("42")). @@ -45,6 +50,7 @@ var keybindings = []keybinding{ {"Ctrl+B 1-9", "Jump to session pane"}, {"Ctrl+B x", "Unpin focused session"}, {"Ctrl+B z", "Toggle zoom session"}, + {"Mouse Wheel", "Scroll session up/down"}, {"?", "Toggle help"}, {"Ctrl+C", "Quit"}, } @@ -65,6 +71,6 @@ func (help HelpModel) View() string { lines = append(lines, fmt.Sprintf("%s %s", key, desc)) } - content := title + "\n" + strings.Join(lines, "\n") + content := title + helpNewline + strings.Join(lines, helpNewline) return helpBorderStyle.Render(content) } diff --git a/internal/tui/help_scroll_test.go b/internal/tui/help_scroll_test.go new file mode 100644 index 0000000..bf0c5b8 --- /dev/null +++ b/internal/tui/help_scroll_test.go @@ -0,0 +1,14 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestHelpShowsScrollKeybinding(testing *testing.T) { + help := NewHelpModel() + view := help.View() + if !strings.Contains(view, "Scroll") { + testing.Error("expected Scroll keybinding in help") + } +} From aa5e5666740a16cb6e5d94670c20f8e46a16337c Mon Sep 17 00:00:00 2001 From: Robin White Date: Wed, 18 Mar 2026 14:55:32 -0400 Subject: [PATCH 14/14] fix: data race in PTYSession test message snapshot --- internal/tui/pty_session_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/tui/pty_session_test.go b/internal/tui/pty_session_test.go index b4779d8..8a53c89 100644 --- a/internal/tui/pty_session_test.go +++ b/internal/tui/pty_session_test.go @@ -47,9 +47,12 @@ func TestPTYSessionStartAndRender(testing *testing.T) { deadline := time.After(ptyTestTimeout) for { - hasExited := checkForExit(&mu, messages) + mu.Lock() + snapshot := make([]PTYOutputMsg, len(messages)) + copy(snapshot, messages) + mu.Unlock() - if hasExited { + if checkForExit(snapshot) { break } @@ -71,10 +74,7 @@ func TestPTYSessionStartAndRender(testing *testing.T) { } } -func checkForExit(mu *sync.Mutex, messages []PTYOutputMsg) bool { - mu.Lock() - defer mu.Unlock() - +func checkForExit(messages []PTYOutputMsg) bool { for _, msg := range messages { if msg.Exited { return true