diff --git a/.gitignore b/.gitignore index 6f37438..23f94c8 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ Thumbs.db # Worktrees .worktrees/ + +# Plans +docs/plans/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c0eb81 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +

+ DJ — Codex TUI Visualizer +

+ +

DJ

+ +

+ A terminal multiplexer for OpenAI Codex CLI agent sessions. +

+ +

+ Install · + Quick Start · + Keybindings · + Configuration · + Architecture +

+ +--- + +DJ is a Go TUI that visualizes and controls Codex CLI agent sessions on a canvas grid. Think of it as **tmux for AI agents** — a dashboard where each agent session is a card you can inspect, open into a full interactive terminal, fork, or kill. + +It communicates with the Codex App Server over JSON-RPC 2.0 via stdio and renders real-time session state using [Bubble Tea](https://github.com/charmbracelet/bubbletea). + +## Install + +### Homebrew + +```bash +brew install robinojw/dj/dj +``` + +### From source + +Requires Go 1.25.4+ and the [Codex CLI](https://github.com/openai/codex) installed in your PATH. + +```bash +git clone https://github.com/robinojw/dj.git +cd dj +go build -o dj ./cmd/dj +./dj +``` + +## Quick Start + +```bash +# Launch DJ — it spawns a Codex app server automatically +dj + +# Or with a custom config +dj --config dj.toml +``` + +DJ starts with a canvas grid showing your agent sessions as cards. Use the arrow keys to navigate between cards and press **Enter** to open a full interactive Codex terminal session. + +## Keybindings + +### Canvas / Tree View + +| Key | Action | +|-----|--------| +| `arrow keys` | Navigate cards or tree nodes | +| `Enter` | Open session (spawns or reconnects PTY) | +| `t` | Toggle canvas / tree view | +| `k` | Kill selected session | +| `?` | Help overlay | +| `Esc` / `Ctrl+C` | Quit | + +### Prefix Mode (tmux-style) + +| Key | Action | +|-----|--------| +| `Ctrl+B` | Enter prefix mode | +| `Ctrl+B` then `m` | Open thread menu (fork / delete / rename) | +| `Ctrl+B` then `n` | Create new thread | + +### Session View + +| Key | Action | +|-----|--------| +| `Esc` | Close session, return to canvas | +| All other keys | Forwarded to the Codex CLI PTY | + +## Configuration + +DJ uses an optional TOML config file. All values have sensible defaults. + +```toml +[appserver] +command = "codex" +args = ["proto"] + +[interactive] +command = "codex" +args = [] + +[ui] +theme = "default" +``` + +Pass a custom config with `--config`: + +```bash +dj --config ~/my-dj.toml +``` + +## Architecture + +DJ is built on an event-driven [Bubble Tea](https://github.com/charmbracelet/bubbletea) architecture with a reactive `ThreadStore` as the single source of truth. + +### Two-process model + +1. **Background process** (`codex proto`) — a long-lived JSON-RPC event stream that delivers structured events (session configured, task started, agent deltas, token counts). Updates the ThreadStore which drives canvas card rendering. + +2. **Interactive processes** (`codex`) — PTY sessions spawned lazily when you open a card. Each gets a real VT100 terminal emulator ([charmbracelet/x/vt](https://github.com/charmbracelet/x)) so you see actual Codex CLI output. + +### Package layout + +``` +cmd/dj/ Entry point +internal/ + appserver/ JSON-RPC 2.0 client, protocol types, message dispatch + state/ Reactive ThreadStore (RWMutex-protected), thread state + config/ Viper-based TOML config loader + tui/ Bubble Tea UI: canvas grid, tree view, session panel, + PTY management, key routing, VT emulator integration +``` + +### Event flow + +``` +codex proto stdout + → Client.ReadLoop() + → app.events channel + → Bubble Tea msgs + → Update() → ThreadStore updated + → View() re-renders canvas +``` + +## Development + +```bash +# Run all tests +go test ./... + +# Single package, verbose +go test ./internal/appserver -v + +# Single test +go test ./internal/appserver -run TestClientCall -v + +# Integration test (requires codex CLI) +go test ./internal/appserver -v -tags=integration + +# Lint +golangci-lint run + +# Build +go build -o dj ./cmd/dj +``` + +CI runs tests with the race detector (`go test -race`) and enforces golangci-lint (govet, staticcheck, funlen, cyclop). + +## License + +[MIT](LICENSE) diff --git a/dj.png b/dj.png new file mode 100644 index 0000000..fdde6ab Binary files /dev/null and b/dj.png differ diff --git a/docs/plans/2026-03-17-00-master-plan.md b/docs/plans/2026-03-17-00-master-plan.md deleted file mode 100644 index 4c3792e..0000000 --- a/docs/plans/2026-03-17-00-master-plan.md +++ /dev/null @@ -1,53 +0,0 @@ -# DJ — Master Implementation Plan - -> **For Claude:** Each phase has its own plan file. Use superpowers:executing-plans to implement each phase. - -**Goal:** Build a Go TUI that visualizes and controls OpenAI Codex CLI agent sessions via the Codex App Server's JSON-RPC 2.0 stdio protocol. - -**Architecture:** Event-driven Bubble Tea (Elm Architecture) with a reactive ThreadStore as the single source of truth. A single app-server child process communicates over stdio JSONL. A dedicated goroutine reads JSON-RPC messages and injects them into the TUI via `program.Send(msg)`. The canvas renders agent cards in a grid, session panes show terminal output, and an agent tree provides hierarchical navigation. - ---- - -## Phase Overview - -| Phase | Name | Status | Plan File | -|-------|------|--------|-----------| -| 1 | Project Scaffold + IPC Client | **Done** | `2026-03-17-01-project-scaffold-ipc.md` | -| 2 | Protocol Types & Dispatch | Planned | `2026-03-17-02-protocol-types-dispatch.md` | -| 3 | State Store & Event Bridge | Planned | `2026-03-17-03-state-store-event-bridge.md` | -| 4 | Canvas UI & Cards | Planned | `2026-03-17-04-canvas-ui-cards.md` | -| 5 | Agent Tree Navigation | Planned | `2026-03-17-05-agent-tree-navigation.md` | -| 6 | Session Pane | Planned | `2026-03-17-06-session-pane.md` | -| 7 | Context Menu, Fork & Delete | Planned | `2026-03-17-07-context-menu-fork-delete.md` | -| 8 | Config, Help & Keybindings | Planned | `2026-03-17-08-config-help-keybindings.md` | -| 9 | Polish & Extensibility | Planned | `2026-03-17-09-polish-extensibility.md` | - ---- - -## Dependency Graph - -``` -Phase 1 (IPC Client) - └─► Phase 2 (Protocol Types) - └─► Phase 3 (State Store) - ├─► Phase 4 (Canvas UI) - │ ├─► Phase 5 (Agent Tree) - │ └─► Phase 6 (Session Pane) - │ └─► Phase 7 (Context Menu) - └─► Phase 8 (Config) - └─► Phase 9 (Polish) -``` - ---- - -## What's Built (Phase 1) - -- Go module `github.com/robinojw/dj` with `cmd/dj/main.go` entry point -- `internal/appserver/protocol.go` — JSON-RPC 2.0 base types (Message, Request, Response, RPCError) -- `internal/appserver/client.go` — Full IPC client: spawn child process, Send, ReadLoop, Call (sync), Dispatch, Initialize handshake -- 8 unit tests + 1 integration test (build-tagged) -- CI: golangci-lint (govet, staticcheck, funlen 60, cyclop 15), 300-line file limit, race detector - -## What's Next (Phase 2) - -Define the Codex App Server-specific protocol types — thread management, message streaming, command execution, and notifications — on top of the generic JSON-RPC 2.0 base. diff --git a/docs/plans/2026-03-17-01-project-scaffold-ipc.md b/docs/plans/2026-03-17-01-project-scaffold-ipc.md deleted file mode 100644 index ac5fd22..0000000 --- a/docs/plans/2026-03-17-01-project-scaffold-ipc.md +++ /dev/null @@ -1,48 +0,0 @@ -# Phase 1: Project Scaffold + App-Server IPC Client - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Initialize the Go project and build a bidirectional JSON-RPC 2.0 client that spawns the Codex App Server, sends requests, and receives notifications over stdio. - -**Architecture:** The client spawns `codex app-server --listen stdio://` as a child process. A dedicated goroutine reads newline-delimited JSON from stdout and dispatches messages. Requests are written to stdin as JSONL with a mutex. A pending-request map tracks `id → response channel` for synchronous-style calls. - -**Tech Stack:** Go 1.22+, `os/exec`, `encoding/json`, `bufio`, `sync` - -**Status:** COMPLETE - ---- - -### Task 1: Initialize Go Module and Directory Structure -- Created `go.mod`, `cmd/dj/main.go` entry point -- Commit: `feat: initialize go module and project scaffold` - -### Task 2: Define JSON-RPC 2.0 Base Types -- `internal/appserver/protocol.go`: Message, Request, Response, RPCError -- 4 tests: marshal/unmarshal for requests, responses, notifications, errors -- Commit: `feat(appserver): define JSON-RPC 2.0 base types` - -### Task 3: Build the IPC Client — Process Lifecycle -- `internal/appserver/client.go`: Client struct, NewClient, Start, Stop, Running -- 1 test using `cat` as mock process -- Commit: `feat(appserver): client process lifecycle start/stop` - -### Task 4: Client Send and ReadLoop -- Send() writes JSONL to stdin, ReadLoop() reads JSONL from stdout -- 1 test: echo round-trip via `cat` -- Commit: `feat(appserver): send requests and read JSONL responses` - -### Task 5: Synchronous Call with Pending Request Tracking -- Call() sends request and blocks until matching response via channel -- Dispatch() routes messages to pending calls, server requests, or notifications -- 1 test: Call + Dispatch via `cat` echo -- Commit: `feat(appserver): synchronous Call with pending request tracking` - -### Task 6: Initialize Handshake -- Initialize() sends `initialize` request, receives capabilities, sends `initialized` notification -- 1 test: mock server with bidirectional io.Pipe -- Commit: `feat(appserver): initialize/initialized handshake` - -### Task 7: Integration Smoke Test -- `integration_test.go` with `//go:build integration` tag -- Tests real `codex app-server` connection when available -- Commit: `test(appserver): integration smoke test with real app-server` diff --git a/docs/plans/2026-03-17-02-protocol-types-dispatch.md b/docs/plans/2026-03-17-02-protocol-types-dispatch.md deleted file mode 100644 index 201b78a..0000000 --- a/docs/plans/2026-03-17-02-protocol-types-dispatch.md +++ /dev/null @@ -1,997 +0,0 @@ -# Phase 2: Protocol Types & Enhanced Dispatch - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Define the Codex App Server-specific protocol types for thread management, message streaming, and command execution. Enhance dispatch to route typed notifications and server requests. - -**Architecture:** Typed request params and result structs wrap `json.RawMessage` for each RPC method. A `NotificationRouter` maps method names to typed handler functions, replacing the generic `OnNotification` callback. Notification types are defined as constants to prevent string repetition. - -**Tech Stack:** Go, `encoding/json` - ---- - -### Task 1: Define Protocol Method Constants - -**Files:** -- Create: `internal/appserver/methods.go` -- Create: `internal/appserver/methods_test.go` - -**Step 1: Write test that method constants match expected strings** - -```go -// internal/appserver/methods_test.go -package appserver - -import "testing" - -func TestMethodConstants(t *testing.T) { - tests := []struct { - name string - constant string - expected string - }{ - {"ThreadCreate", MethodThreadCreate, "thread/create"}, - {"ThreadList", MethodThreadList, "thread/list"}, - {"ThreadDelete", MethodThreadDelete, "thread/delete"}, - {"ThreadSendMessage", MethodThreadSendMessage, "thread/sendMessage"}, - {"CommandExec", MethodCommandExec, "command/exec"}, - {"NotifyThreadStatus", NotifyThreadStatusChanged, "thread/status/changed"}, - {"NotifyThreadMessage", NotifyThreadMessageCreated, "thread/message/created"}, - {"NotifyMessageDelta", NotifyThreadMessageDelta, "thread/message/delta"}, - {"NotifyCommandOutput", NotifyCommandOutput, "command/output"}, - {"NotifyCommandFinished", NotifyCommandFinished, "command/finished"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.constant != tt.expected { - t.Errorf("expected %q, got %q", tt.expected, tt.constant) - } - }) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/appserver/ -v -run TestMethodConstants` -Expected: FAIL — constants not defined - -**Step 3: Implement method constants** - -```go -// internal/appserver/methods.go -package appserver - -// Client-to-server request methods. -const ( - MethodThreadCreate = "thread/create" - MethodThreadList = "thread/list" - MethodThreadDelete = "thread/delete" - MethodThreadSendMessage = "thread/sendMessage" - MethodCommandExec = "command/exec" -) - -// Server-to-client notification methods. -const ( - NotifyThreadStatusChanged = "thread/status/changed" - NotifyThreadMessageCreated = "thread/message/created" - NotifyThreadMessageDelta = "thread/message/delta" - NotifyCommandOutput = "command/output" - NotifyCommandFinished = "command/finished" -) -``` - -**Step 4: Run tests** - -Run: `go test ./internal/appserver/ -v -run TestMethodConstants` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/appserver/methods.go internal/appserver/methods_test.go -git commit -m "feat(appserver): define protocol method constants" -``` - ---- - -### Task 2: Define Thread Management Types - -**Files:** -- Create: `internal/appserver/types_thread.go` -- Create: `internal/appserver/types_thread_test.go` - -**Step 1: Write tests for thread type marshaling** - -```go -// internal/appserver/types_thread_test.go -package appserver - -import ( - "encoding/json" - "testing" -) - -func TestThreadCreateParamsMarshal(t *testing.T) { - params := ThreadCreateParams{ - Instructions: "Build a web server", - } - data, err := json.Marshal(params) - if err != nil { - t.Fatal(err) - } - var parsed map[string]any - json.Unmarshal(data, &parsed) - if parsed["instructions"] != "Build a web server" { - t.Errorf("expected instructions, got %v", parsed["instructions"]) - } -} - -func TestThreadCreateResultUnmarshal(t *testing.T) { - raw := `{"threadId":"t-abc123"}` - var result ThreadCreateResult - if err := json.Unmarshal([]byte(raw), &result); err != nil { - t.Fatal(err) - } - if result.ThreadID != "t-abc123" { - t.Errorf("expected t-abc123, got %s", result.ThreadID) - } -} - -func TestThreadListResultUnmarshal(t *testing.T) { - raw := `{"threads":[{"id":"t-1","status":"active","title":"Test"}]}` - var result ThreadListResult - if err := json.Unmarshal([]byte(raw), &result); err != nil { - t.Fatal(err) - } - if len(result.Threads) != 1 { - t.Fatalf("expected 1 thread, got %d", len(result.Threads)) - } - if result.Threads[0].ID != "t-1" { - t.Errorf("expected id t-1, got %s", result.Threads[0].ID) - } - if result.Threads[0].Status != "active" { - t.Errorf("expected status active, got %s", result.Threads[0].Status) - } -} - -func TestThreadStatusValues(t *testing.T) { - if ThreadStatusActive != "active" { - t.Errorf("expected active, got %s", ThreadStatusActive) - } - if ThreadStatusCompleted != "completed" { - t.Errorf("expected completed, got %s", ThreadStatusCompleted) - } - if ThreadStatusError != "error" { - t.Errorf("expected error, got %s", ThreadStatusError) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/appserver/ -v -run TestThread` -Expected: FAIL — types not defined - -**Step 3: Implement thread types** - -```go -// internal/appserver/types_thread.go -package appserver - -// Thread status constants. -const ( - ThreadStatusActive = "active" - ThreadStatusIdle = "idle" - ThreadStatusCompleted = "completed" - ThreadStatusError = "error" -) - -// ThreadCreateParams is the params for thread/create. -type ThreadCreateParams struct { - Instructions string `json:"instructions"` -} - -// ThreadCreateResult is the result of thread/create. -type ThreadCreateResult struct { - ThreadID string `json:"threadId"` -} - -// ThreadDeleteParams is the params for thread/delete. -type ThreadDeleteParams struct { - ThreadID string `json:"threadId"` -} - -// ThreadListResult is the result of thread/list. -type ThreadListResult struct { - Threads []ThreadSummary `json:"threads"` -} - -// ThreadSummary is a thread entry in the thread/list result. -type ThreadSummary struct { - ID string `json:"id"` - Status string `json:"status"` - Title string `json:"title"` -} - -// ThreadSendMessageParams is the params for thread/sendMessage. -type ThreadSendMessageParams struct { - ThreadID string `json:"threadId"` - Content string `json:"content"` -} - -// ThreadSendMessageResult is the result of thread/sendMessage. -type ThreadSendMessageResult struct { - MessageID string `json:"messageId"` -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/appserver/ -v -run TestThread` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/appserver/types_thread.go internal/appserver/types_thread_test.go -git commit -m "feat(appserver): define thread management protocol types" -``` - ---- - -### Task 3: Define Notification Types - -**Files:** -- Create: `internal/appserver/types_notify.go` -- Create: `internal/appserver/types_notify_test.go` - -**Step 1: Write tests for notification type unmarshaling** - -```go -// internal/appserver/types_notify_test.go -package appserver - -import ( - "encoding/json" - "testing" -) - -func TestThreadStatusChangedUnmarshal(t *testing.T) { - raw := `{"threadId":"t-1","status":"completed","title":"Done"}` - var params ThreadStatusChanged - if err := json.Unmarshal([]byte(raw), ¶ms); err != nil { - t.Fatal(err) - } - if params.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", params.ThreadID) - } - if params.Status != ThreadStatusCompleted { - t.Errorf("expected completed, got %s", params.Status) - } -} - -func TestThreadMessageCreatedUnmarshal(t *testing.T) { - raw := `{"threadId":"t-1","messageId":"m-1","role":"assistant","content":"Hello"}` - var params ThreadMessageCreated - if err := json.Unmarshal([]byte(raw), ¶ms); err != nil { - t.Fatal(err) - } - if params.Role != "assistant" { - t.Errorf("expected assistant, got %s", params.Role) - } - if params.Content != "Hello" { - t.Errorf("expected Hello, got %s", params.Content) - } -} - -func TestThreadMessageDeltaUnmarshal(t *testing.T) { - raw := `{"threadId":"t-1","messageId":"m-1","delta":"more text"}` - var params ThreadMessageDelta - if err := json.Unmarshal([]byte(raw), ¶ms); err != nil { - t.Fatal(err) - } - if params.Delta != "more text" { - t.Errorf("expected 'more text', got %s", params.Delta) - } -} - -func TestCommandOutputUnmarshal(t *testing.T) { - raw := `{"threadId":"t-1","execId":"e-1","data":"line of output\n"}` - var params CommandOutput - if err := json.Unmarshal([]byte(raw), ¶ms); err != nil { - t.Fatal(err) - } - if params.ExecID != "e-1" { - t.Errorf("expected e-1, got %s", params.ExecID) - } -} - -func TestCommandFinishedUnmarshal(t *testing.T) { - raw := `{"threadId":"t-1","execId":"e-1","exitCode":0}` - var params CommandFinished - if err := json.Unmarshal([]byte(raw), ¶ms); err != nil { - t.Fatal(err) - } - if params.ExitCode != 0 { - t.Errorf("expected exit code 0, got %d", params.ExitCode) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/appserver/ -v -run "TestThread(Status|Message)|TestCommand"` -Expected: FAIL — types not defined - -**Step 3: Implement notification types** - -```go -// internal/appserver/types_notify.go -package appserver - -// ThreadStatusChanged is the params for thread/status/changed notifications. -type ThreadStatusChanged struct { - ThreadID string `json:"threadId"` - Status string `json:"status"` - Title string `json:"title"` -} - -// ThreadMessageCreated is the params for thread/message/created notifications. -type ThreadMessageCreated struct { - ThreadID string `json:"threadId"` - MessageID string `json:"messageId"` - Role string `json:"role"` - Content string `json:"content"` -} - -// ThreadMessageDelta is the params for thread/message/delta notifications. -type ThreadMessageDelta struct { - ThreadID string `json:"threadId"` - MessageID string `json:"messageId"` - Delta string `json:"delta"` -} - -// CommandOutput is the params for command/output notifications. -type CommandOutput struct { - ThreadID string `json:"threadId"` - ExecID string `json:"execId"` - Data string `json:"data"` -} - -// CommandFinished is the params for command/finished notifications. -type CommandFinished struct { - ThreadID string `json:"threadId"` - ExecID string `json:"execId"` - ExitCode int `json:"exitCode"` -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/appserver/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/appserver/types_notify.go internal/appserver/types_notify_test.go -git commit -m "feat(appserver): define notification protocol types" -``` - ---- - -### Task 4: Define Command Execution Types - -**Files:** -- Create: `internal/appserver/types_command.go` -- Create: `internal/appserver/types_command_test.go` - -**Step 1: Write tests for command types** - -```go -// internal/appserver/types_command_test.go -package appserver - -import ( - "encoding/json" - "testing" -) - -func TestCommandExecParamsMarshal(t *testing.T) { - params := CommandExecParams{ - ThreadID: "t-1", - Command: "go test ./...", - TTY: true, - } - data, err := json.Marshal(params) - if err != nil { - t.Fatal(err) - } - var parsed map[string]any - json.Unmarshal(data, &parsed) - if parsed["tty"] != true { - t.Errorf("expected tty true, got %v", parsed["tty"]) - } -} - -func TestCommandExecResultUnmarshal(t *testing.T) { - raw := `{"execId":"e-abc123"}` - var result CommandExecResult - if err := json.Unmarshal([]byte(raw), &result); err != nil { - t.Fatal(err) - } - if result.ExecID != "e-abc123" { - t.Errorf("expected e-abc123, got %s", result.ExecID) - } -} - -func TestConfirmExecParamsUnmarshal(t *testing.T) { - raw := `{"threadId":"t-1","command":"rm -rf /tmp/test"}` - var params ConfirmExecParams - if err := json.Unmarshal([]byte(raw), ¶ms); err != nil { - t.Fatal(err) - } - if params.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", params.ThreadID) - } - if params.Command != "rm -rf /tmp/test" { - t.Errorf("expected command, got %s", params.Command) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/appserver/ -v -run "TestCommandExec|TestConfirmExec"` -Expected: FAIL — types not defined - -**Step 3: Implement command types** - -```go -// internal/appserver/types_command.go -package appserver - -// CommandExecParams is the params for command/exec. -type CommandExecParams struct { - ThreadID string `json:"threadId"` - Command string `json:"command"` - TTY bool `json:"tty"` -} - -// CommandExecResult is the result of command/exec. -type CommandExecResult struct { - ExecID string `json:"execId"` -} - -// ConfirmExecParams is a server-to-client request asking the user -// to confirm a command before execution. -type ConfirmExecParams struct { - ThreadID string `json:"threadId"` - Command string `json:"command"` -} - -// ConfirmExecResult is the client's response to a confirm exec request. -type ConfirmExecResult struct { - Approved bool `json:"approved"` -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/appserver/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/appserver/types_command.go internal/appserver/types_command_test.go -git commit -m "feat(appserver): define command execution protocol types" -``` - ---- - -### Task 5: Build NotificationRouter - -**Files:** -- Create: `internal/appserver/router.go` -- Create: `internal/appserver/router_test.go` - -**Step 1: Write tests for notification routing** - -```go -// internal/appserver/router_test.go -package appserver - -import ( - "encoding/json" - "sync/atomic" - "testing" -) - -func TestRouterDispatchesNotification(t *testing.T) { - router := NewNotificationRouter() - - var called atomic.Bool - router.OnThreadStatusChanged(func(params ThreadStatusChanged) { - called.Store(true) - if params.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", params.ThreadID) - } - }) - - raw := json.RawMessage(`{"threadId":"t-1","status":"active","title":"Test"}`) - router.Handle(NotifyThreadStatusChanged, raw) - - if !called.Load() { - t.Error("handler was not called") - } -} - -func TestRouterIgnoresUnregisteredMethod(t *testing.T) { - router := NewNotificationRouter() - router.Handle("unknown/method", json.RawMessage(`{}`)) -} - -func TestRouterDispatchesMessageDelta(t *testing.T) { - router := NewNotificationRouter() - - var receivedDelta string - router.OnThreadMessageDelta(func(params ThreadMessageDelta) { - receivedDelta = params.Delta - }) - - raw := json.RawMessage(`{"threadId":"t-1","messageId":"m-1","delta":"hello"}`) - router.Handle(NotifyThreadMessageDelta, raw) - - if receivedDelta != "hello" { - t.Errorf("expected hello, got %s", receivedDelta) - } -} - -func TestRouterDispatchesCommandOutput(t *testing.T) { - router := NewNotificationRouter() - - var receivedData string - router.OnCommandOutput(func(params CommandOutput) { - receivedData = params.Data - }) - - raw := json.RawMessage(`{"threadId":"t-1","execId":"e-1","data":"output line\n"}`) - router.Handle(NotifyCommandOutput, raw) - - if receivedData != "output line\n" { - t.Errorf("expected output, got %s", receivedData) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/appserver/ -v -run TestRouter` -Expected: FAIL — `NewNotificationRouter` not defined - -**Step 3: Implement NotificationRouter** - -```go -// internal/appserver/router.go -package appserver - -import "encoding/json" - -// NotificationRouter dispatches typed notifications by method name. -type NotificationRouter struct { - handlers map[string]func(json.RawMessage) -} - -// NewNotificationRouter creates an empty router. -func NewNotificationRouter() *NotificationRouter { - return &NotificationRouter{ - handlers: make(map[string]func(json.RawMessage)), - } -} - -// Handle dispatches a notification to its registered handler. -func (r *NotificationRouter) Handle(method string, params json.RawMessage) { - handler, exists := r.handlers[method] - if !exists { - return - } - handler(params) -} - -// OnThreadStatusChanged registers a handler for thread/status/changed. -func (r *NotificationRouter) OnThreadStatusChanged(fn func(ThreadStatusChanged)) { - r.handlers[NotifyThreadStatusChanged] = func(raw json.RawMessage) { - var params ThreadStatusChanged - if err := json.Unmarshal(raw, ¶ms); err != nil { - return - } - fn(params) - } -} - -// OnThreadMessageCreated registers a handler for thread/message/created. -func (r *NotificationRouter) OnThreadMessageCreated(fn func(ThreadMessageCreated)) { - r.handlers[NotifyThreadMessageCreated] = func(raw json.RawMessage) { - var params ThreadMessageCreated - if err := json.Unmarshal(raw, ¶ms); err != nil { - return - } - fn(params) - } -} - -// OnThreadMessageDelta registers a handler for thread/message/delta. -func (r *NotificationRouter) OnThreadMessageDelta(fn func(ThreadMessageDelta)) { - r.handlers[NotifyThreadMessageDelta] = func(raw json.RawMessage) { - var params ThreadMessageDelta - if err := json.Unmarshal(raw, ¶ms); err != nil { - return - } - fn(params) - } -} - -// OnCommandOutput registers a handler for command/output. -func (r *NotificationRouter) OnCommandOutput(fn func(CommandOutput)) { - r.handlers[NotifyCommandOutput] = func(raw json.RawMessage) { - var params CommandOutput - if err := json.Unmarshal(raw, ¶ms); err != nil { - return - } - fn(params) - } -} - -// OnCommandFinished registers a handler for command/finished. -func (r *NotificationRouter) OnCommandFinished(fn func(CommandFinished)) { - r.handlers[NotifyCommandFinished] = func(raw json.RawMessage) { - var params CommandFinished - if err := json.Unmarshal(raw, ¶ms); err != nil { - return - } - fn(params) - } -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/appserver/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/appserver/router.go internal/appserver/router_test.go -git commit -m "feat(appserver): typed notification router" -``` - ---- - -### Task 6: Typed Client Helper Methods - -**Files:** -- Create: `internal/appserver/client_thread.go` -- Create: `internal/appserver/client_thread_test.go` - -**Step 1: Write tests for typed thread helpers** - -```go -// internal/appserver/client_thread_test.go -package appserver - -import ( - "bufio" - "context" - "encoding/json" - "io" - "testing" - "time" -) - -func TestClientCreateThread(t *testing.T) { - clientRead, serverWrite := io.Pipe() - serverRead, clientWrite := io.Pipe() - - go mockThreadCreateServer(t, serverRead, serverWrite) - - client := &Client{} - client.stdin = clientWrite - client.stdout = clientRead - client.scanner = bufio.NewScanner(clientRead) - client.scanner.Buffer(make([]byte, 1024*1024), 1024*1024) - client.running.Store(true) - - go client.ReadLoop(client.Dispatch) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result, err := client.CreateThread(ctx, "Build a web app") - if err != nil { - t.Fatalf("CreateThread failed: %v", err) - } - if result.ThreadID != "t-new-123" { - t.Errorf("expected t-new-123, got %s", result.ThreadID) - } -} - -func mockThreadCreateServer(t *testing.T, reader *io.PipeReader, writer *io.PipeWriter) { - t.Helper() - scanner := bufio.NewScanner(reader) - scanner.Buffer(make([]byte, 1024*1024), 1024*1024) - - if !scanner.Scan() { - t.Error("mock: failed to read request") - return - } - var req Message - if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { - t.Errorf("mock: unmarshal: %v", err) - return - } - - resp := Message{ - JSONRPC: "2.0", - ID: req.ID, - Result: json.RawMessage(`{"threadId":"t-new-123"}`), - } - data, _ := json.Marshal(resp) - data = append(data, '\n') - writer.Write(data) -} - -func TestClientListThreads(t *testing.T) { - clientRead, serverWrite := io.Pipe() - serverRead, clientWrite := io.Pipe() - - go mockThreadListServer(t, serverRead, serverWrite) - - client := &Client{} - client.stdin = clientWrite - client.stdout = clientRead - client.scanner = bufio.NewScanner(clientRead) - client.scanner.Buffer(make([]byte, 1024*1024), 1024*1024) - client.running.Store(true) - - go client.ReadLoop(client.Dispatch) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result, err := client.ListThreads(ctx) - if err != nil { - t.Fatalf("ListThreads failed: %v", err) - } - if len(result.Threads) != 2 { - t.Fatalf("expected 2 threads, got %d", len(result.Threads)) - } -} - -func mockThreadListServer(t *testing.T, reader *io.PipeReader, writer *io.PipeWriter) { - t.Helper() - scanner := bufio.NewScanner(reader) - scanner.Buffer(make([]byte, 1024*1024), 1024*1024) - - if !scanner.Scan() { - t.Error("mock: failed to read request") - return - } - var req Message - if err := json.Unmarshal(scanner.Bytes(), &req); err != nil { - t.Errorf("mock: unmarshal: %v", err) - return - } - - resp := Message{ - JSONRPC: "2.0", - ID: req.ID, - Result: json.RawMessage(`{"threads":[{"id":"t-1","status":"active","title":"A"},{"id":"t-2","status":"idle","title":"B"}]}`), - } - data, _ := json.Marshal(resp) - data = append(data, '\n') - writer.Write(data) -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/appserver/ -v -run "TestClientCreate|TestClientList"` -Expected: FAIL — methods not defined - -**Step 3: Implement typed client helpers** - -```go -// internal/appserver/client_thread.go -package appserver - -import ( - "context" - "encoding/json" - "fmt" -) - -// CreateThread sends a thread/create request and returns the new thread ID. -func (c *Client) CreateThread(ctx context.Context, instructions string) (*ThreadCreateResult, error) { - params, _ := json.Marshal(ThreadCreateParams{ - Instructions: instructions, - }) - - resp, err := c.Call(ctx, MethodThreadCreate, params) - if err != nil { - return nil, fmt.Errorf("thread/create: %w", err) - } - if resp.Error != nil { - return nil, fmt.Errorf("thread/create: %w", resp.Error) - } - - var result ThreadCreateResult - if err := json.Unmarshal(resp.Result, &result); err != nil { - return nil, fmt.Errorf("unmarshal thread/create result: %w", err) - } - return &result, nil -} - -// ListThreads sends a thread/list request and returns all threads. -func (c *Client) ListThreads(ctx context.Context) (*ThreadListResult, error) { - resp, err := c.Call(ctx, MethodThreadList, json.RawMessage(`{}`)) - if err != nil { - return nil, fmt.Errorf("thread/list: %w", err) - } - if resp.Error != nil { - return nil, fmt.Errorf("thread/list: %w", resp.Error) - } - - var result ThreadListResult - if err := json.Unmarshal(resp.Result, &result); err != nil { - return nil, fmt.Errorf("unmarshal thread/list result: %w", err) - } - return &result, nil -} - -// DeleteThread sends a thread/delete request. -func (c *Client) DeleteThread(ctx context.Context, threadID string) error { - params, _ := json.Marshal(ThreadDeleteParams{ - ThreadID: threadID, - }) - - resp, err := c.Call(ctx, MethodThreadDelete, params) - if err != nil { - return fmt.Errorf("thread/delete: %w", err) - } - if resp.Error != nil { - return fmt.Errorf("thread/delete: %w", resp.Error) - } - return nil -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/appserver/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/appserver/client_thread.go internal/appserver/client_thread_test.go -git commit -m "feat(appserver): typed thread management client methods" -``` - ---- - -### Task 7: Wire Router into Client Dispatch - -**Files:** -- Modify: `internal/appserver/client.go` -- Create: `internal/appserver/dispatch_test.go` - -**Step 1: Write test for router-based dispatch** - -```go -// internal/appserver/dispatch_test.go -package appserver - -import ( - "encoding/json" - "sync/atomic" - "testing" -) - -func TestDispatchRoutesNotificationToRouter(t *testing.T) { - client := &Client{} - router := NewNotificationRouter() - - var called atomic.Bool - router.OnThreadStatusChanged(func(params ThreadStatusChanged) { - called.Store(true) - if params.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", params.ThreadID) - } - }) - - client.Router = router - - msg := Message{ - JSONRPC: "2.0", - Method: NotifyThreadStatusChanged, - Params: json.RawMessage(`{"threadId":"t-1","status":"active","title":"Test"}`), - } - client.Dispatch(msg) - - if !called.Load() { - t.Error("router handler was not called") - } -} - -func TestDispatchFallsBackToOnNotification(t *testing.T) { - client := &Client{} - - var called atomic.Bool - client.OnNotification = func(method string, params json.RawMessage) { - called.Store(true) - } - - msg := Message{ - JSONRPC: "2.0", - Method: "custom/notification", - Params: json.RawMessage(`{}`), - } - client.Dispatch(msg) - - if !called.Load() { - t.Error("OnNotification was not called") - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/appserver/ -v -run TestDispatchRoutes` -Expected: FAIL — `Router` field not defined on Client - -**Step 3: Add Router field to Client and update Dispatch** - -In `internal/appserver/client.go`, add the Router field to the Client struct: - -```go -// Add to Client struct, after OnServerRequest: - // Router dispatches typed notifications by method name. - // Falls back to OnNotification for unregistered methods. - Router *NotificationRouter -``` - -Update the notification section of Dispatch: - -```go -// Replace the notification section in Dispatch: - // Notification (no ID) - if msg.Method == "" { - return - } - - routerHandled := r.Router != nil - if routerHandled { - r.Router.Handle(msg.Method, msg.Params) - } - - if c.OnNotification != nil { - c.OnNotification(msg.Method, msg.Params) - } -``` - -> **Note:** Both Router and OnNotification fire — Router for typed handling, OnNotification as a generic hook (useful for logging or the event bridge in Phase 3). - -**Step 4: Run tests** - -Run: `go test ./internal/appserver/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/appserver/client.go internal/appserver/dispatch_test.go -git commit -m "feat(appserver): wire notification router into dispatch" -``` diff --git a/docs/plans/2026-03-17-03-state-store-event-bridge.md b/docs/plans/2026-03-17-03-state-store-event-bridge.md deleted file mode 100644 index 26f5b8d..0000000 --- a/docs/plans/2026-03-17-03-state-store-event-bridge.md +++ /dev/null @@ -1,661 +0,0 @@ -# Phase 3: State Store & Event Bridge - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Build the reactive ThreadStore that holds all thread state, and the Bubble Tea event bridge that converts JSON-RPC notifications into `tea.Msg` types. The store is the single source of truth — the TUI never queries the app-server directly for display data. - -**Architecture:** `ThreadStore` is a mutex-protected map of `threadID → ThreadState`. Each `ThreadState` holds metadata, messages, and command output. The event bridge is a set of `tea.Msg` types and a function that wires the `NotificationRouter` to call `program.Send(msg)` for each event. Bubble Tea's `Update` function then calls store mutations. - -**Tech Stack:** Go, `sync.Mutex`, Bubble Tea `tea.Msg` - ---- - -### Task 1: Define ThreadState Type - -**Files:** -- Create: `internal/state/thread.go` -- Create: `internal/state/thread_test.go` - -**Step 1: Write tests for ThreadState** - -```go -// internal/state/thread_test.go -package state - -import "testing" - -func TestNewThreadState(t *testing.T) { - thread := NewThreadState("t-1", "Build a web app") - if thread.ID != "t-1" { - t.Errorf("expected t-1, got %s", thread.ID) - } - if thread.Status != StatusIdle { - t.Errorf("expected idle, got %s", thread.Status) - } - if len(thread.Messages) != 0 { - t.Errorf("expected 0 messages, got %d", len(thread.Messages)) - } -} - -func TestThreadStateAppendMessage(t *testing.T) { - thread := NewThreadState("t-1", "Test") - thread.AppendMessage(ChatMessage{ - ID: "m-1", - Role: "user", - Content: "Hello", - }) - if len(thread.Messages) != 1 { - t.Fatalf("expected 1 message, got %d", len(thread.Messages)) - } - if thread.Messages[0].Content != "Hello" { - t.Errorf("expected Hello, got %s", thread.Messages[0].Content) - } -} - -func TestThreadStateAppendDelta(t *testing.T) { - thread := NewThreadState("t-1", "Test") - thread.AppendMessage(ChatMessage{ID: "m-1", Role: "assistant", Content: "He"}) - thread.AppendDelta("m-1", "llo") - - if thread.Messages[0].Content != "Hello" { - t.Errorf("expected Hello, got %s", thread.Messages[0].Content) - } -} - -func TestThreadStateAppendOutput(t *testing.T) { - thread := NewThreadState("t-1", "Test") - thread.AppendOutput("e-1", "line 1\n") - thread.AppendOutput("e-1", "line 2\n") - - output := thread.CommandOutput["e-1"] - if output != "line 1\nline 2\n" { - t.Errorf("expected combined output, got %q", output) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/state/ -v` -Expected: FAIL — package not found - -**Step 3: Implement ThreadState** - -```go -// internal/state/thread.go -package state - -// Thread status constants. -const ( - StatusIdle = "idle" - StatusActive = "active" - StatusCompleted = "completed" - StatusError = "error" -) - -// ChatMessage is a single message in a thread's conversation. -type ChatMessage struct { - ID string - Role string - Content string -} - -// ThreadState holds all state for a single agent thread. -type ThreadState struct { - ID string - Title string - Status string - Messages []ChatMessage - CommandOutput map[string]string // execId → accumulated output -} - -// NewThreadState creates an idle thread with the given ID and title. -func NewThreadState(id string, title string) *ThreadState { - return &ThreadState{ - ID: id, - Title: title, - Status: StatusIdle, - Messages: make([]ChatMessage, 0), - CommandOutput: make(map[string]string), - } -} - -// AppendMessage adds a new message to the thread. -func (ts *ThreadState) AppendMessage(msg ChatMessage) { - ts.Messages = append(ts.Messages, msg) -} - -// AppendDelta appends streaming text to an existing message. -func (ts *ThreadState) AppendDelta(messageID string, delta string) { - for i := range ts.Messages { - if ts.Messages[i].ID == messageID { - ts.Messages[i].Content += delta - return - } - } -} - -// AppendOutput appends command output for the given exec ID. -func (ts *ThreadState) AppendOutput(execID string, data string) { - ts.CommandOutput[execID] += data -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/state/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/state/thread.go internal/state/thread_test.go -git commit -m "feat(state): define ThreadState with messages and command output" -``` - ---- - -### Task 2: Build ThreadStore - -**Files:** -- Create: `internal/state/store.go` -- Create: `internal/state/store_test.go` - -**Step 1: Write tests for ThreadStore** - -```go -// internal/state/store_test.go -package state - -import "testing" - -func TestStoreAddAndGet(t *testing.T) { - store := NewThreadStore() - store.Add("t-1", "My Thread") - - thread, exists := store.Get("t-1") - if !exists { - t.Fatal("expected thread to exist") - } - if thread.Title != "My Thread" { - t.Errorf("expected My Thread, got %s", thread.Title) - } -} - -func TestStoreGetMissing(t *testing.T) { - store := NewThreadStore() - _, exists := store.Get("missing") - if exists { - t.Error("expected thread to not exist") - } -} - -func TestStoreDelete(t *testing.T) { - store := NewThreadStore() - store.Add("t-1", "Test") - store.Delete("t-1") - - _, exists := store.Get("t-1") - if exists { - t.Error("expected thread to be deleted") - } -} - -func TestStoreAll(t *testing.T) { - store := NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") - - all := store.All() - if len(all) != 2 { - t.Fatalf("expected 2 threads, got %d", len(all)) - } -} - -func TestStoreUpdateStatus(t *testing.T) { - store := NewThreadStore() - store.Add("t-1", "Test") - store.UpdateStatus("t-1", StatusActive, "Running") - - thread, _ := store.Get("t-1") - if thread.Status != StatusActive { - t.Errorf("expected active, got %s", thread.Status) - } - if thread.Title != "Running" { - t.Errorf("expected Running, got %s", thread.Title) - } -} - -func TestStoreUpdateStatusMissing(t *testing.T) { - store := NewThreadStore() - store.UpdateStatus("missing", StatusActive, "Test") -} - -func TestStoreIDs(t *testing.T) { - store := NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") - - ids := store.IDs() - if len(ids) != 2 { - t.Fatalf("expected 2 ids, got %d", len(ids)) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/state/ -v -run TestStore` -Expected: FAIL — `NewThreadStore` not defined - -**Step 3: Implement ThreadStore** - -```go -// internal/state/store.go -package state - -import ( - "sort" - "sync" -) - -// ThreadStore is the single source of truth for all thread state. -// All methods are safe for concurrent use. -type ThreadStore struct { - mu sync.RWMutex - threads map[string]*ThreadState - order []string // insertion order for stable iteration -} - -// NewThreadStore creates an empty store. -func NewThreadStore() *ThreadStore { - return &ThreadStore{ - threads: make(map[string]*ThreadState), - order: make([]string, 0), - } -} - -// Add creates a new thread in the store. -func (s *ThreadStore) Add(id string, title string) { - s.mu.Lock() - defer s.mu.Unlock() - - s.threads[id] = NewThreadState(id, title) - s.order = append(s.order, id) -} - -// Get returns a snapshot of the thread state. Returns false if not found. -func (s *ThreadStore) Get(id string) (*ThreadState, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - - thread, exists := s.threads[id] - return thread, exists -} - -// Delete removes a thread from the store. -func (s *ThreadStore) Delete(id string) { - s.mu.Lock() - defer s.mu.Unlock() - - delete(s.threads, id) - s.order = removeFromSlice(s.order, id) -} - -// All returns all threads in insertion order. -func (s *ThreadStore) All() []*ThreadState { - s.mu.RLock() - defer s.mu.RUnlock() - - result := make([]*ThreadState, 0, len(s.order)) - for _, id := range s.order { - if thread, exists := s.threads[id]; exists { - result = append(result, thread) - } - } - return result -} - -// IDs returns all thread IDs in sorted order. -func (s *ThreadStore) IDs() []string { - s.mu.RLock() - defer s.mu.RUnlock() - - ids := make([]string, 0, len(s.threads)) - for id := range s.threads { - ids = append(ids, id) - } - sort.Strings(ids) - return ids -} - -// UpdateStatus updates a thread's status and title. -func (s *ThreadStore) UpdateStatus(id string, status string, title string) { - s.mu.Lock() - defer s.mu.Unlock() - - thread, exists := s.threads[id] - if !exists { - return - } - thread.Status = status - if title != "" { - thread.Title = title - } -} - -func removeFromSlice(slice []string, target string) []string { - result := make([]string, 0, len(slice)) - for _, item := range slice { - if item != target { - result = append(result, item) - } - } - return result -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/state/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/state/store.go internal/state/store_test.go -git commit -m "feat(state): ThreadStore with concurrent-safe CRUD" -``` - ---- - -### Task 3: Define Bubble Tea Message Types - -**Files:** -- Create: `internal/tui/msgs.go` -- Create: `internal/tui/msgs_test.go` - -**Step 1: Write tests for message types** - -```go -// internal/tui/msgs_test.go -package tui - -import "testing" - -func TestMsgTypes(t *testing.T) { - statusMsg := ThreadStatusMsg{ - ThreadID: "t-1", - Status: "active", - Title: "Running", - } - if statusMsg.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", statusMsg.ThreadID) - } - - messageMsg := ThreadMessageMsg{ - ThreadID: "t-1", - MessageID: "m-1", - Role: "assistant", - Content: "Hello", - } - if messageMsg.Role != "assistant" { - t.Errorf("expected assistant, got %s", messageMsg.Role) - } - - deltaMsg := ThreadDeltaMsg{ - ThreadID: "t-1", - MessageID: "m-1", - Delta: "world", - } - if deltaMsg.Delta != "world" { - t.Errorf("expected world, got %s", deltaMsg.Delta) - } - - outputMsg := CommandOutputMsg{ - ThreadID: "t-1", - ExecID: "e-1", - Data: "output\n", - } - if outputMsg.Data != "output\n" { - t.Errorf("expected output, got %s", outputMsg.Data) - } - - finishedMsg := CommandFinishedMsg{ - ThreadID: "t-1", - ExecID: "e-1", - ExitCode: 0, - } - if finishedMsg.ExitCode != 0 { - t.Errorf("expected 0, got %d", finishedMsg.ExitCode) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui/ -v -run TestMsgTypes` -Expected: FAIL — types not defined - -**Step 3: Implement message types** - -```go -// internal/tui/msgs.go -package tui - -// ThreadStatusMsg is sent when a thread's status changes. -type ThreadStatusMsg struct { - ThreadID string - Status string - Title string -} - -// ThreadMessageMsg is sent when a new message is created in a thread. -type ThreadMessageMsg struct { - ThreadID string - MessageID string - Role string - Content string -} - -// ThreadDeltaMsg is sent for streaming message deltas. -type ThreadDeltaMsg struct { - ThreadID string - MessageID string - Delta string -} - -// CommandOutputMsg is sent when command output arrives. -type CommandOutputMsg struct { - ThreadID string - ExecID string - Data string -} - -// CommandFinishedMsg is sent when a command finishes execution. -type CommandFinishedMsg struct { - ThreadID string - ExecID string - ExitCode int -} - -// ThreadCreatedMsg is sent when a new thread is created. -type ThreadCreatedMsg struct { - ThreadID string - Title string -} - -// ThreadDeletedMsg is sent when a thread is deleted. -type ThreadDeletedMsg struct { - ThreadID string -} - -// AppServerErrorMsg is sent when the app-server connection has an error. -type AppServerErrorMsg struct { - Err error -} - -func (m AppServerErrorMsg) Error() string { - return m.Err.Error() -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/tui/msgs.go internal/tui/msgs_test.go -git commit -m "feat(tui): define Bubble Tea message types for event bridge" -``` - ---- - -### Task 4: Build Event Bridge - -**Files:** -- Create: `internal/tui/bridge.go` -- Create: `internal/tui/bridge_test.go` - -**Step 1: Write tests for event bridge** - -```go -// internal/tui/bridge_test.go -package tui - -import ( - "testing" - - "github.com/robinojw/dj/internal/appserver" -) - -type mockSender struct { - messages []any -} - -func (m *mockSender) Send(msg any) { - m.messages = append(m.messages, msg) -} - -func TestBridgeThreadStatusChanged(t *testing.T) { - sender := &mockSender{} - router := appserver.NewNotificationRouter() - WireEventBridge(router, sender) - - router.Handle(appserver.NotifyThreadStatusChanged, - []byte(`{"threadId":"t-1","status":"active","title":"Running"}`)) - - if len(sender.messages) != 1 { - t.Fatalf("expected 1 message, got %d", len(sender.messages)) - } - msg, ok := sender.messages[0].(ThreadStatusMsg) - if !ok { - t.Fatalf("expected ThreadStatusMsg, got %T", sender.messages[0]) - } - if msg.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", msg.ThreadID) - } -} - -func TestBridgeCommandOutput(t *testing.T) { - sender := &mockSender{} - router := appserver.NewNotificationRouter() - WireEventBridge(router, sender) - - router.Handle(appserver.NotifyCommandOutput, - []byte(`{"threadId":"t-1","execId":"e-1","data":"hello\n"}`)) - - if len(sender.messages) != 1 { - t.Fatalf("expected 1 message, got %d", len(sender.messages)) - } - msg, ok := sender.messages[0].(CommandOutputMsg) - if !ok { - t.Fatalf("expected CommandOutputMsg, got %T", sender.messages[0]) - } - if msg.Data != "hello\n" { - t.Errorf("expected hello, got %s", msg.Data) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui/ -v -run TestBridge` -Expected: FAIL — `WireEventBridge` not defined - -**Step 3: Implement event bridge** - -```go -// internal/tui/bridge.go -package tui - -import "github.com/robinojw/dj/internal/appserver" - -// MessageSender is an interface for sending Bubble Tea messages. -// In production this is *tea.Program; in tests it's a mock. -type MessageSender interface { - Send(msg any) -} - -// WireEventBridge registers notification handlers on the router -// that convert appserver notifications into Bubble Tea messages. -func WireEventBridge(router *appserver.NotificationRouter, sender MessageSender) { - router.OnThreadStatusChanged(func(params appserver.ThreadStatusChanged) { - sender.Send(ThreadStatusMsg{ - ThreadID: params.ThreadID, - Status: params.Status, - Title: params.Title, - }) - }) - - router.OnThreadMessageCreated(func(params appserver.ThreadMessageCreated) { - sender.Send(ThreadMessageMsg{ - ThreadID: params.ThreadID, - MessageID: params.MessageID, - Role: params.Role, - Content: params.Content, - }) - }) - - router.OnThreadMessageDelta(func(params appserver.ThreadMessageDelta) { - sender.Send(ThreadDeltaMsg{ - ThreadID: params.ThreadID, - MessageID: params.MessageID, - Delta: params.Delta, - }) - }) - - router.OnCommandOutput(func(params appserver.CommandOutput) { - sender.Send(CommandOutputMsg{ - ThreadID: params.ThreadID, - ExecID: params.ExecID, - Data: params.Data, - }) - }) - - router.OnCommandFinished(func(params appserver.CommandFinished) { - sender.Send(CommandFinishedMsg{ - ThreadID: params.ThreadID, - ExecID: params.ExecID, - ExitCode: params.ExitCode, - }) - }) -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v` -Expected: PASS - -> **Note:** This task introduces the first cross-package dependency. Run `go mod tidy` after if needed, but since it's all within the module no external deps are added. - -**Step 5: Commit** - -```bash -git add internal/tui/bridge.go internal/tui/bridge_test.go -git commit -m "feat(tui): event bridge wires appserver notifications to Bubble Tea" -``` diff --git a/docs/plans/2026-03-17-04-canvas-ui-cards.md b/docs/plans/2026-03-17-04-canvas-ui-cards.md deleted file mode 100644 index e5390de..0000000 --- a/docs/plans/2026-03-17-04-canvas-ui-cards.md +++ /dev/null @@ -1,659 +0,0 @@ -# Phase 4: Canvas UI & Agent Cards - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Build the Bubble Tea application shell with a canvas grid of agent cards. Each card renders a thread's status, title, and recent activity. Arrow keys navigate between cards. The selected card is highlighted. - -**Architecture:** The `App` model is the root Bubble Tea model. It owns the `ThreadStore`, the `appserver.Client`, and the canvas layout. The canvas is a grid of `CardModel` components rendered via Lipgloss. The `App.Update` method handles keyboard input and event bridge messages, mutating the store and triggering re-renders. - -**Tech Stack:** Go, Bubble Tea, Lipgloss - -**Prerequisites:** Phase 3 (state store, message types, event bridge) - ---- - -### Task 1: Add Bubble Tea and Lipgloss Dependencies - -**Files:** -- Modify: `go.mod` - -**Step 1: Add dependencies** - -```bash -go get github.com/charmbracelet/bubbletea -go get github.com/charmbracelet/lipgloss -go mod tidy -``` - -**Step 2: Verify** - -Run: `go build ./...` -Expected: builds successfully - -**Step 3: Commit** - -```bash -git add go.mod go.sum -git commit -m "deps: add Bubble Tea and Lipgloss" -``` - ---- - -### Task 2: Build Agent Card Component - -**Files:** -- Create: `internal/tui/card.go` -- Create: `internal/tui/card_test.go` - -**Step 1: Write tests for card rendering** - -```go -// internal/tui/card_test.go -package tui - -import ( - "strings" - "testing" - - "github.com/robinojw/dj/internal/state" -) - -func TestCardRenderShowsTitle(t *testing.T) { - thread := state.NewThreadState("t-1", "Build web app") - thread.Status = state.StatusActive - - card := NewCardModel(thread, false) - output := card.View() - - if !strings.Contains(output, "Build web app") { - t.Errorf("expected title in output, got:\n%s", output) - } -} - -func TestCardRenderShowsStatus(t *testing.T) { - thread := state.NewThreadState("t-1", "Test") - thread.Status = state.StatusActive - - card := NewCardModel(thread, false) - output := card.View() - - if !strings.Contains(output, "active") { - t.Errorf("expected status in output, got:\n%s", output) - } -} - -func TestCardRenderSelectedHighlight(t *testing.T) { - thread := state.NewThreadState("t-1", "Test") - card := NewCardModel(thread, true) - selected := card.View() - - card2 := NewCardModel(thread, false) - unselected := card2.View() - - if selected == unselected { - t.Error("selected and unselected cards should differ") - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui/ -v -run TestCard` -Expected: FAIL — `NewCardModel` not defined - -**Step 3: Implement card component** - -```go -// internal/tui/card.go -package tui - -import ( - "fmt" - - "github.com/charmbracelet/lipgloss" - "github.com/robinojw/dj/internal/state" -) - -const ( - cardWidth = 30 - cardHeight = 6 -) - -var ( - cardStyle = lipgloss.NewStyle(). - Width(cardWidth). - Height(cardHeight). - Border(lipgloss.RoundedBorder()). - Padding(0, 1) - - selectedCardStyle = cardStyle. - BorderForeground(lipgloss.Color("39")) - - statusColors = map[string]lipgloss.Color{ - state.StatusActive: lipgloss.Color("42"), - state.StatusIdle: lipgloss.Color("245"), - state.StatusCompleted: lipgloss.Color("34"), - state.StatusError: lipgloss.Color("196"), - } -) - -// CardModel renders a single agent thread as a card. -type CardModel struct { - thread *state.ThreadState - selected bool -} - -// NewCardModel creates a card for the given thread. -func NewCardModel(thread *state.ThreadState, selected bool) CardModel { - return CardModel{ - thread: thread, - selected: selected, - } -} - -// View renders the card. -func (c CardModel) View() string { - statusColor, exists := statusColors[c.thread.Status] - if !exists { - statusColor = lipgloss.Color("245") - } - - statusLine := lipgloss.NewStyle(). - Foreground(statusColor). - Render(c.thread.Status) - - title := truncate(c.thread.Title, cardWidth-4) - content := fmt.Sprintf("%s\n%s", title, statusLine) - - style := cardStyle - if c.selected { - style = selectedCardStyle - } - - return style.Render(content) -} - -func truncate(text string, maxLen int) string { - if len(text) <= maxLen { - return text - } - return text[:maxLen-3] + "..." -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v -run TestCard` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/tui/card.go internal/tui/card_test.go -git commit -m "feat(tui): agent card component with status colors" -``` - ---- - -### Task 3: Build Canvas Grid Layout - -**Files:** -- Create: `internal/tui/canvas.go` -- Create: `internal/tui/canvas_test.go` - -**Step 1: Write tests for canvas grid** - -```go -// internal/tui/canvas_test.go -package tui - -import ( - "testing" - - "github.com/robinojw/dj/internal/state" -) - -func TestCanvasNavigation(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") - store.Add("t-3", "Third") - - canvas := NewCanvasModel(store) - - if canvas.SelectedIndex() != 0 { - t.Errorf("expected initial index 0, got %d", canvas.SelectedIndex()) - } - - canvas.MoveRight() - if canvas.SelectedIndex() != 1 { - t.Errorf("expected index 1 after right, got %d", canvas.SelectedIndex()) - } - - canvas.MoveLeft() - if canvas.SelectedIndex() != 0 { - t.Errorf("expected index 0 after left, got %d", canvas.SelectedIndex()) - } -} - -func TestCanvasNavigationBounds(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") - - canvas := NewCanvasModel(store) - - canvas.MoveLeft() - if canvas.SelectedIndex() != 0 { - t.Errorf("expected clamped at 0, got %d", canvas.SelectedIndex()) - } - - canvas.MoveRight() - canvas.MoveRight() - if canvas.SelectedIndex() != 1 { - t.Errorf("expected clamped at 1, got %d", canvas.SelectedIndex()) - } -} - -func TestCanvasSelectedThreadID(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") - - canvas := NewCanvasModel(store) - canvas.MoveRight() - - id := canvas.SelectedThreadID() - if id != "t-2" { - t.Errorf("expected t-2, got %s", id) - } -} - -func TestCanvasEmptyStore(t *testing.T) { - store := state.NewThreadStore() - canvas := NewCanvasModel(store) - - if canvas.SelectedThreadID() != "" { - t.Errorf("expected empty ID for empty canvas") - } - - canvas.MoveRight() - if canvas.SelectedIndex() != 0 { - t.Errorf("expected 0 for empty canvas") - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui/ -v -run TestCanvas` -Expected: FAIL — `NewCanvasModel` not defined - -**Step 3: Implement canvas grid** - -```go -// internal/tui/canvas.go -package tui - -import ( - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/robinojw/dj/internal/state" -) - -const canvasColumns = 3 - -// CanvasModel manages a grid of agent cards. -type CanvasModel struct { - store *state.ThreadStore - selected int -} - -// NewCanvasModel creates a canvas backed by the given store. -func NewCanvasModel(store *state.ThreadStore) CanvasModel { - return CanvasModel{store: store} -} - -// SelectedIndex returns the current selection index. -func (c *CanvasModel) SelectedIndex() int { - return c.selected -} - -// SelectedThreadID returns the ID of the selected thread. -func (c *CanvasModel) SelectedThreadID() string { - threads := c.store.All() - if len(threads) == 0 { - return "" - } - return threads[c.selected].ID -} - -// MoveRight advances selection to the next card. -func (c *CanvasModel) MoveRight() { - threads := c.store.All() - if c.selected < len(threads)-1 { - c.selected++ - } -} - -// MoveLeft moves selection to the previous card. -func (c *CanvasModel) MoveLeft() { - if c.selected > 0 { - c.selected-- - } -} - -// MoveDown moves selection down one row. -func (c *CanvasModel) MoveDown() { - threads := c.store.All() - next := c.selected + canvasColumns - if next < len(threads) { - c.selected = next - } -} - -// MoveUp moves selection up one row. -func (c *CanvasModel) MoveUp() { - next := c.selected - canvasColumns - if next >= 0 { - c.selected = next - } -} - -// View renders the canvas grid. -func (c *CanvasModel) View() string { - threads := c.store.All() - if len(threads) == 0 { - return "No active threads. Press 'n' to create one." - } - - var rows []string - for i := 0; i < len(threads); i += canvasColumns { - end := i + canvasColumns - if end > len(threads) { - end = len(threads) - } - - var cards []string - for j := i; j < end; j++ { - isSelected := j == c.selected - card := NewCardModel(threads[j], isSelected) - cards = append(cards, card.View()) - } - - rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, cards...)) - } - - return strings.Join(rows, "\n") -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/tui/canvas.go internal/tui/canvas_test.go -git commit -m "feat(tui): canvas grid layout with arrow key navigation" -``` - ---- - -### Task 4: Build Root App Model - -**Files:** -- Create: `internal/tui/app.go` -- Create: `internal/tui/app_test.go` - -**Step 1: Write tests for app model** - -```go -// internal/tui/app_test.go -package tui - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/robinojw/dj/internal/state" -) - -func TestAppHandlesArrowKeys(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") - - app := NewAppModel(store) - - rightKey := tea.KeyMsg{Type: tea.KeyRight} - updated, _ := app.Update(rightKey) - appModel := updated.(AppModel) - - if appModel.canvas.SelectedIndex() != 1 { - t.Errorf("expected index 1 after right, got %d", appModel.canvas.SelectedIndex()) - } -} - -func TestAppHandlesThreadStatusMsg(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Initial") - - app := NewAppModel(store) - - msg := ThreadStatusMsg{ - ThreadID: "t-1", - Status: "active", - Title: "Running", - } - app.Update(msg) - - thread, _ := store.Get("t-1") - if thread.Status != "active" { - t.Errorf("expected active, got %s", thread.Status) - } - if thread.Title != "Running" { - t.Errorf("expected Running, got %s", thread.Title) - } -} - -func TestAppHandlesQuit(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - quitKey := tea.KeyMsg{Type: tea.KeyCtrlC} - _, cmd := app.Update(quitKey) - - if cmd == nil { - t.Fatal("expected quit command") - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui/ -v -run TestApp` -Expected: FAIL — `NewAppModel` not defined - -**Step 3: Implement app model** - -```go -// internal/tui/app.go -package tui - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/robinojw/dj/internal/state" -) - -var titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("39")). - MarginBottom(1) - -// AppModel is the root Bubble Tea model. -type AppModel struct { - store *state.ThreadStore - canvas CanvasModel - width int - height int -} - -// NewAppModel creates the root app with the given store. -func NewAppModel(store *state.ThreadStore) AppModel { - return AppModel{ - store: store, - canvas: NewCanvasModel(store), - } -} - -// Init returns the initial command. -func (m AppModel) Init() tea.Cmd { - return nil -} - -// Update handles messages. -func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - return m.handleKey(msg) - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - case ThreadStatusMsg: - m.store.UpdateStatus(msg.ThreadID, msg.Status, msg.Title) - return m, nil - case ThreadMessageMsg: - return m.handleThreadMessage(msg) - case ThreadDeltaMsg: - return m.handleThreadDelta(msg) - case CommandOutputMsg: - return m.handleCommandOutput(msg) - } - return m, nil -} - -func (m AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyCtrlC, tea.KeyEsc: - return m, tea.Quit - case tea.KeyRight, tea.KeyTab: - m.canvas.MoveRight() - case tea.KeyLeft, tea.KeyShiftTab: - m.canvas.MoveLeft() - case tea.KeyDown: - m.canvas.MoveDown() - case tea.KeyUp: - m.canvas.MoveUp() - } - return m, nil -} - -func (m AppModel) handleThreadMessage(msg ThreadMessageMsg) (tea.Model, tea.Cmd) { - thread, exists := m.store.Get(msg.ThreadID) - if !exists { - return m, nil - } - thread.AppendMessage(state.ChatMessage{ - ID: msg.MessageID, - Role: msg.Role, - Content: msg.Content, - }) - return m, nil -} - -func (m AppModel) handleThreadDelta(msg ThreadDeltaMsg) (tea.Model, tea.Cmd) { - thread, exists := m.store.Get(msg.ThreadID) - if !exists { - return m, nil - } - thread.AppendDelta(msg.MessageID, msg.Delta) - return m, nil -} - -func (m AppModel) handleCommandOutput(msg CommandOutputMsg) (tea.Model, tea.Cmd) { - thread, exists := m.store.Get(msg.ThreadID) - if !exists { - return m, nil - } - thread.AppendOutput(msg.ExecID, msg.Data) - return m, nil -} - -// View renders the full UI. -func (m AppModel) View() string { - title := titleStyle.Render("DJ — Codex TUI Visualizer") - canvas := m.canvas.View() - return title + "\n" + canvas + "\n" -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/tui/app.go internal/tui/app_test.go -git commit -m "feat(tui): root App model with canvas and event handling" -``` - ---- - -### Task 5: Wire main.go to Launch TUI - -**Files:** -- Modify: `cmd/dj/main.go` - -**Step 1: Update main.go to start Bubble Tea** - -```go -// cmd/dj/main.go -package main - -import ( - "fmt" - "os" - - tea "github.com/charmbracelet/bubbletea" - "github.com/robinojw/dj/internal/state" - "github.com/robinojw/dj/internal/tui" -) - -func main() { - if err := run(); err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) - } -} - -func run() error { - store := state.NewThreadStore() - app := tui.NewAppModel(store) - - program := tea.NewProgram(app, tea.WithAltScreen()) - _, err := program.Run() - return err -} -``` - -**Step 2: Verify it builds and runs** - -Run: `go build ./cmd/dj && echo "Build OK"` -Expected: Build OK - -> Note: Running `./dj` will show the TUI with an empty canvas. Press Ctrl+C to exit. - -**Step 3: Commit** - -```bash -git add cmd/dj/main.go -git commit -m "feat: wire main.go to launch Bubble Tea TUI" -``` diff --git a/docs/plans/2026-03-17-05-agent-tree-navigation.md b/docs/plans/2026-03-17-05-agent-tree-navigation.md deleted file mode 100644 index bcad67f..0000000 --- a/docs/plans/2026-03-17-05-agent-tree-navigation.md +++ /dev/null @@ -1,408 +0,0 @@ -# Phase 5: Agent Tree Navigation - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Build a hierarchical tree view sidebar for navigating parent/child agent threads. The tree shows thread relationships and allows keyboard navigation. Selecting a tree node updates the canvas selection. - -**Architecture:** `TreeModel` is a Bubble Tea component that renders a tree from `ThreadStore` data. Threads can have parent IDs, forming a hierarchy. The tree supports expand/collapse with Enter, and Up/Down navigation. A `parentID` field is added to `ThreadState`. The App model gains a focus system toggling between canvas and tree views. - -**Tech Stack:** Go, Bubble Tea, Lipgloss - -**Prerequisites:** Phase 4 (canvas UI, app model) - ---- - -### Task 1: Add Parent Tracking to ThreadState - -**Files:** -- Modify: `internal/state/thread.go` -- Modify: `internal/state/store.go` -- Create: `internal/state/tree_test.go` - -**Step 1: Write tests for parent-child relationships** - -```go -// internal/state/tree_test.go -package state - -import "testing" - -func TestThreadStateParentID(t *testing.T) { - thread := NewThreadState("t-child", "Child Task") - thread.ParentID = "t-parent" - - if thread.ParentID != "t-parent" { - t.Errorf("expected t-parent, got %s", thread.ParentID) - } -} - -func TestStoreChildren(t *testing.T) { - store := NewThreadStore() - store.Add("t-root", "Root") - store.AddWithParent("t-child-1", "Child 1", "t-root") - store.AddWithParent("t-child-2", "Child 2", "t-root") - - children := store.Children("t-root") - if len(children) != 2 { - t.Fatalf("expected 2 children, got %d", len(children)) - } -} - -func TestStoreRoots(t *testing.T) { - store := NewThreadStore() - store.Add("t-root-1", "Root 1") - store.Add("t-root-2", "Root 2") - store.AddWithParent("t-child", "Child", "t-root-1") - - roots := store.Roots() - if len(roots) != 2 { - t.Fatalf("expected 2 roots, got %d", len(roots)) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/state/ -v -run "TestThreadStateParent|TestStoreChildren|TestStoreRoots"` -Expected: FAIL — `ParentID` field and methods not defined - -**Step 3: Add ParentID to ThreadState and new store methods** - -Add `ParentID string` field to `ThreadState` in `thread.go`. - -Add to `store.go`: - -```go -// AddWithParent creates a new thread with a parent relationship. -func (s *ThreadStore) AddWithParent(id string, title string, parentID string) { - s.mu.Lock() - defer s.mu.Unlock() - - thread := NewThreadState(id, title) - thread.ParentID = parentID - s.threads[id] = thread - s.order = append(s.order, id) -} - -// Children returns all threads whose parent is the given ID. -func (s *ThreadStore) Children(parentID string) []*ThreadState { - s.mu.RLock() - defer s.mu.RUnlock() - - var children []*ThreadState - for _, id := range s.order { - thread := s.threads[id] - if thread.ParentID == parentID { - children = append(children, thread) - } - } - return children -} - -// Roots returns all threads with no parent. -func (s *ThreadStore) Roots() []*ThreadState { - s.mu.RLock() - defer s.mu.RUnlock() - - var roots []*ThreadState - for _, id := range s.order { - thread := s.threads[id] - if thread.ParentID == "" { - roots = append(roots, thread) - } - } - return roots -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/state/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/state/thread.go internal/state/store.go internal/state/tree_test.go -git commit -m "feat(state): add parent-child thread relationships" -``` - ---- - -### Task 2: Build Tree Component - -**Files:** -- Create: `internal/tui/tree.go` -- Create: `internal/tui/tree_test.go` - -**Step 1: Write tests for tree rendering and navigation** - -```go -// internal/tui/tree_test.go -package tui - -import ( - "strings" - "testing" - - "github.com/robinojw/dj/internal/state" -) - -func TestTreeRender(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Root Task") - store.AddWithParent("t-2", "Subtask A", "t-1") - - tree := NewTreeModel(store) - output := tree.View() - - if !strings.Contains(output, "Root Task") { - t.Errorf("expected Root Task in output:\n%s", output) - } - if !strings.Contains(output, "Subtask A") { - t.Errorf("expected Subtask A in output:\n%s", output) - } -} - -func TestTreeNavigation(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") - - tree := NewTreeModel(store) - - if tree.SelectedID() != "t-1" { - t.Errorf("expected t-1, got %s", tree.SelectedID()) - } - - tree.MoveDown() - if tree.SelectedID() != "t-2" { - t.Errorf("expected t-2, got %s", tree.SelectedID()) - } - - tree.MoveUp() - if tree.SelectedID() != "t-1" { - t.Errorf("expected t-1, got %s", tree.SelectedID()) - } -} - -func TestTreeEmpty(t *testing.T) { - store := state.NewThreadStore() - tree := NewTreeModel(store) - - if tree.SelectedID() != "" { - t.Errorf("expected empty ID, got %s", tree.SelectedID()) - } - - output := tree.View() - if !strings.Contains(output, "No threads") { - t.Errorf("expected empty message:\n%s", output) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui/ -v -run TestTree` -Expected: FAIL — `NewTreeModel` not defined - -**Step 3: Implement tree component** - -```go -// internal/tui/tree.go -package tui - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/robinojw/dj/internal/state" -) - -var ( - treeSelectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("39")). - Bold(true) - treeNormalStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("252")) - treeDimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")) -) - -// TreeModel renders a hierarchical list of threads. -type TreeModel struct { - store *state.ThreadStore - selected int - flatList []string // flattened thread IDs in display order -} - -// NewTreeModel creates a tree backed by the given store. -func NewTreeModel(store *state.ThreadStore) TreeModel { - tree := TreeModel{store: store} - tree.rebuild() - return tree -} - -// SelectedID returns the thread ID of the selected node. -func (t *TreeModel) SelectedID() string { - if len(t.flatList) == 0 { - return "" - } - return t.flatList[t.selected] -} - -// MoveDown moves selection down. -func (t *TreeModel) MoveDown() { - if t.selected < len(t.flatList)-1 { - t.selected++ - } -} - -// MoveUp moves selection up. -func (t *TreeModel) MoveUp() { - if t.selected > 0 { - t.selected-- - } -} - -// Refresh rebuilds the flat list from the store. -func (t *TreeModel) Refresh() { - t.rebuild() -} - -func (t *TreeModel) rebuild() { - t.flatList = nil - roots := t.store.Roots() - for _, root := range roots { - t.flatList = append(t.flatList, root.ID) - t.addChildren(root.ID, 1) - } -} - -func (t *TreeModel) addChildren(parentID string, depth int) { - children := t.store.Children(parentID) - for _, child := range children { - t.flatList = append(t.flatList, child.ID) - t.addChildren(child.ID, depth+1) - } -} - -func (t *TreeModel) depthOf(id string) int { - thread, exists := t.store.Get(id) - if !exists || thread.ParentID == "" { - return 0 - } - return 1 + t.depthOf(thread.ParentID) -} - -// View renders the tree. -func (t *TreeModel) View() string { - if len(t.flatList) == 0 { - return treeDimStyle.Render("No threads") - } - - var lines []string - for i, id := range t.flatList { - thread, exists := t.store.Get(id) - if !exists { - continue - } - - depth := t.depthOf(id) - indent := strings.Repeat(" ", depth) - prefix := "├─" - if depth == 0 { - prefix = "●" - } - - label := fmt.Sprintf("%s%s %s", indent, prefix, thread.Title) - - style := treeNormalStyle - if i == t.selected { - style = treeSelectedStyle - } - lines = append(lines, style.Render(label)) - } - - return strings.Join(lines, "\n") -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/tui/tree.go internal/tui/tree_test.go -git commit -m "feat(tui): hierarchical agent tree component" -``` - ---- - -### Task 3: Add Focus System to App Model - -**Files:** -- Modify: `internal/tui/app.go` -- Modify: `internal/tui/app_test.go` - -**Step 1: Write tests for focus toggling** - -Add to `app_test.go`: - -```go -func TestAppToggleFocus(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Test") - - app := NewAppModel(store) - - if app.Focus() != FocusCanvas { - t.Errorf("expected canvas focus, got %d", app.Focus()) - } - - tabKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}} - updated, _ := app.Update(tabKey) - appModel := updated.(AppModel) - - if appModel.Focus() != FocusTree { - t.Errorf("expected tree focus, got %d", appModel.Focus()) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui/ -v -run TestAppToggle` -Expected: FAIL — `Focus`, `FocusCanvas`, `FocusTree` not defined - -**Step 3: Implement focus system** - -Add focus constants and tree to `app.go`: - -```go -// Focus panel constants. -const ( - FocusCanvas = iota - FocusTree -) -``` - -Add `tree TreeModel` and `focus int` fields to `AppModel`. Update `NewAppModel` to initialize the tree. Add `Focus() int` method. Update `handleKey` to handle `'t'` for toggling focus, and route arrow keys to the focused panel. - -Update `View()` to render tree sidebar alongside canvas when tree is focused. - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/tui/app.go internal/tui/app_test.go -git commit -m "feat(tui): focus system toggling between canvas and tree" -``` diff --git a/docs/plans/2026-03-17-06-session-pane.md b/docs/plans/2026-03-17-06-session-pane.md deleted file mode 100644 index c6a7fa4..0000000 --- a/docs/plans/2026-03-17-06-session-pane.md +++ /dev/null @@ -1,390 +0,0 @@ -# Phase 6: Session Pane - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Build a scrollable session pane that displays a selected thread's conversation messages and command output. The pane opens when Enter is pressed on a canvas card and shows the full conversation history with streaming delta support. - -**Architecture:** `SessionModel` is a Bubble Tea component that renders a thread's messages and command output in a scrollable viewport. It subscribes to the selected thread ID and re-renders when the store changes. The viewport uses Bubble Tea's `viewport` component from the Bubbles library for scrolling. The App model gains a third focus mode (FocusSession) with Esc to return to canvas. - -**Tech Stack:** Go, Bubble Tea, Lipgloss, Bubbles viewport - -**Prerequisites:** Phase 5 (agent tree, focus system) - ---- - -### Task 1: Add Bubbles Viewport Dependency - -**Files:** -- Modify: `go.mod` - -**Step 1: Add dependency** - -```bash -go get github.com/charmbracelet/bubbles -go mod tidy -``` - -**Step 2: Verify** - -Run: `go build ./...` -Expected: builds successfully - -**Step 3: Commit** - -```bash -git add go.mod go.sum -git commit -m "deps: add Bubbles component library" -``` - ---- - -### Task 2: Build Session Message Renderer - -**Files:** -- Create: `internal/tui/session_render.go` -- Create: `internal/tui/session_render_test.go` - -**Step 1: Write tests for message rendering** - -```go -// internal/tui/session_render_test.go -package tui - -import ( - "strings" - "testing" - - "github.com/robinojw/dj/internal/state" -) - -func TestRenderMessages(t *testing.T) { - thread := state.NewThreadState("t-1", "Test") - thread.AppendMessage(state.ChatMessage{ - ID: "m-1", Role: "user", Content: "Hello", - }) - thread.AppendMessage(state.ChatMessage{ - ID: "m-2", Role: "assistant", Content: "Hi there", - }) - - output := RenderMessages(thread) - - if !strings.Contains(output, "Hello") { - t.Errorf("expected user message in output:\n%s", output) - } - if !strings.Contains(output, "Hi there") { - t.Errorf("expected assistant message in output:\n%s", output) - } -} - -func TestRenderMessagesWithCommand(t *testing.T) { - thread := state.NewThreadState("t-1", "Test") - thread.AppendOutput("e-1", "go test ./...\nPASS\n") - - output := RenderMessages(thread) - - if !strings.Contains(output, "PASS") { - t.Errorf("expected command output in output:\n%s", output) - } -} - -func TestRenderMessagesEmpty(t *testing.T) { - thread := state.NewThreadState("t-1", "Test") - output := RenderMessages(thread) - - if !strings.Contains(output, "No messages") { - t.Errorf("expected empty state message:\n%s", output) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui/ -v -run TestRenderMessages` -Expected: FAIL — `RenderMessages` not defined - -**Step 3: Implement message renderer** - -```go -// internal/tui/session_render.go -package tui - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/robinojw/dj/internal/state" -) - -var ( - userStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("42")). - Bold(true) - assistantStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("39")). - Bold(true) - commandStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("214")) - outputStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")) - emptyStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")). - Italic(true) -) - -// RenderMessages produces a formatted string of all messages and command output. -func RenderMessages(thread *state.ThreadState) string { - hasMessages := len(thread.Messages) > 0 - hasOutput := len(thread.CommandOutput) > 0 - - if !hasMessages && !hasOutput { - return emptyStyle.Render("No messages yet. Waiting for activity...") - } - - var sections []string - - for _, msg := range thread.Messages { - label := formatRole(msg.Role) - sections = append(sections, fmt.Sprintf("%s\n%s", label, msg.Content)) - } - - for execID, output := range thread.CommandOutput { - header := commandStyle.Render(fmt.Sprintf("Command [%s]:", execID)) - body := outputStyle.Render(output) - sections = append(sections, fmt.Sprintf("%s\n%s", header, body)) - } - - return strings.Join(sections, "\n\n") -} - -func formatRole(role string) string { - switch role { - case "user": - return userStyle.Render("You:") - case "assistant": - return assistantStyle.Render("Agent:") - default: - return lipgloss.NewStyle().Bold(true).Render(role + ":") - } -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v -run TestRenderMessages` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/tui/session_render.go internal/tui/session_render_test.go -git commit -m "feat(tui): session message renderer with role formatting" -``` - ---- - -### Task 3: Build Session Pane with Viewport - -**Files:** -- Create: `internal/tui/session.go` -- Create: `internal/tui/session_test.go` - -**Step 1: Write tests for session pane** - -```go -// internal/tui/session_test.go -package tui - -import ( - "strings" - "testing" - - "github.com/robinojw/dj/internal/state" -) - -func TestSessionViewShowsThreadTitle(t *testing.T) { - thread := state.NewThreadState("t-1", "My Task") - thread.Status = state.StatusActive - - session := NewSessionModel(thread) - output := session.View() - - if !strings.Contains(output, "My Task") { - t.Errorf("expected thread title in output:\n%s", output) - } -} - -func TestSessionViewShowsMessages(t *testing.T) { - thread := state.NewThreadState("t-1", "Test") - thread.AppendMessage(state.ChatMessage{ - ID: "m-1", Role: "user", Content: "Hello world", - }) - - session := NewSessionModel(thread) - session.SetSize(80, 24) - output := session.View() - - if !strings.Contains(output, "Hello world") { - t.Errorf("expected message content in output:\n%s", output) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui/ -v -run TestSession` -Expected: FAIL — `NewSessionModel` not defined - -**Step 3: Implement session pane** - -```go -// internal/tui/session.go -package tui - -import ( - "fmt" - - "github.com/charmbracelet/bubbles/viewport" - "github.com/charmbracelet/lipgloss" - "github.com/robinojw/dj/internal/state" -) - -const sessionHeaderHeight = 3 - -var sessionHeaderStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("39")). - BorderStyle(lipgloss.NormalBorder()). - BorderBottom(true). - BorderForeground(lipgloss.Color("240")) - -// SessionModel displays a thread's conversation in a scrollable pane. -type SessionModel struct { - thread *state.ThreadState - viewport viewport.Model - ready bool -} - -// NewSessionModel creates a session pane for the given thread. -func NewSessionModel(thread *state.ThreadState) SessionModel { - return SessionModel{thread: thread} -} - -// SetSize sets the viewport dimensions. -func (s *SessionModel) SetSize(width int, height int) { - viewHeight := height - sessionHeaderHeight - if viewHeight < 1 { - viewHeight = 1 - } - s.viewport = viewport.New(width, viewHeight) - s.viewport.SetContent(RenderMessages(s.thread)) - s.ready = true -} - -// Refresh re-renders the content from the thread state. -func (s *SessionModel) Refresh() { - if s.ready { - s.viewport.SetContent(RenderMessages(s.thread)) - s.viewport.GotoBottom() - } -} - -// View renders the session pane. -func (s SessionModel) View() string { - header := sessionHeaderStyle.Render( - fmt.Sprintf("%s [%s]", s.thread.Title, s.thread.Status), - ) - - if !s.ready { - return header + "\n" + RenderMessages(s.thread) - } - - return header + "\n" + s.viewport.View() -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/tui/session.go internal/tui/session_test.go -git commit -m "feat(tui): scrollable session pane with viewport" -``` - ---- - -### Task 4: Wire Session into App Model - -**Files:** -- Modify: `internal/tui/app.go` -- Modify: `internal/tui/app_test.go` - -**Step 1: Write tests for Enter to open session and Esc to close** - -Add to `app_test.go`: - -```go -func TestAppEnterOpensSession(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Test Task") - - app := NewAppModel(store) - - enterKey := tea.KeyMsg{Type: tea.KeyEnter} - updated, _ := app.Update(enterKey) - appModel := updated.(AppModel) - - if appModel.Focus() != FocusSession { - t.Errorf("expected session focus, got %d", appModel.Focus()) - } -} - -func TestAppEscClosesSession(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Test Task") - - app := NewAppModel(store) - - enterKey := tea.KeyMsg{Type: tea.KeyEnter} - updated, _ := app.Update(enterKey) - appModel := updated.(AppModel) - - escKey := tea.KeyMsg{Type: tea.KeyEsc} - updated, _ = appModel.Update(escKey) - appModel = updated.(AppModel) - - if appModel.Focus() != FocusCanvas { - t.Errorf("expected canvas focus after Esc, got %d", appModel.Focus()) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui/ -v -run "TestAppEnter|TestAppEsc"` -Expected: FAIL — `FocusSession` not defined - -**Step 3: Add FocusSession mode** - -Add `FocusSession` to focus constants. Add `session *SessionModel` field to AppModel. In `handleKey`: -- Enter on canvas → create SessionModel for selected thread, set focus to FocusSession -- Esc in session → set focus back to FocusCanvas -- In session focus, route Up/Down to viewport scrolling - -Update `View()` to render session pane when FocusSession is active. - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/tui/app.go internal/tui/app_test.go -git commit -m "feat(tui): Enter opens session pane, Esc returns to canvas" -``` diff --git a/docs/plans/2026-03-17-07-context-menu-fork-delete.md b/docs/plans/2026-03-17-07-context-menu-fork-delete.md deleted file mode 100644 index a6a1ef2..0000000 --- a/docs/plans/2026-03-17-07-context-menu-fork-delete.md +++ /dev/null @@ -1,480 +0,0 @@ -# Phase 7: Context Menu, Fork & Delete - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Build an overlay context menu for thread operations (fork, delete, rename). Implement the `Ctrl+B` prefix key system for pane operations (tmux-style). Wire fork/delete operations through the app-server client. - -**Architecture:** `ContextMenuModel` is an overlay component rendered on top of the canvas or session pane. It appears when pressing `Ctrl+B` followed by `m` (menu). Menu items trigger client RPC calls and update the store. Fork creates a child thread via the API. Delete removes a thread. The prefix key system buffers `Ctrl+B` and waits for the next keystroke. - -**Tech Stack:** Go, Bubble Tea, Lipgloss - -**Prerequisites:** Phase 6 (session pane, full focus system) - ---- - -### Task 1: Build Prefix Key Handler - -**Files:** -- Create: `internal/tui/prefix.go` -- Create: `internal/tui/prefix_test.go` - -**Step 1: Write tests for prefix key detection** - -```go -// internal/tui/prefix_test.go -package tui - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" -) - -func TestPrefixKeyCapture(t *testing.T) { - prefix := NewPrefixHandler() - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - result := prefix.HandleKey(ctrlB) - - if result != PrefixWaiting { - t.Errorf("expected waiting, got %d", result) - } - if !prefix.Active() { - t.Error("expected prefix to be active") - } -} - -func TestPrefixKeyFollowUp(t *testing.T) { - prefix := NewPrefixHandler() - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - prefix.HandleKey(ctrlB) - - mKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}} - result := prefix.HandleKey(mKey) - - if result != PrefixComplete { - t.Errorf("expected complete, got %d", result) - } - if prefix.Action() != 'm' { - t.Errorf("expected 'm', got %c", prefix.Action()) - } - if prefix.Active() { - t.Error("expected prefix to be inactive after completion") - } -} - -func TestPrefixKeyTimeout(t *testing.T) { - prefix := NewPrefixHandler() - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - prefix.HandleKey(ctrlB) - - escKey := tea.KeyMsg{Type: tea.KeyEsc} - result := prefix.HandleKey(escKey) - - if result != PrefixCancelled { - t.Errorf("expected cancelled, got %d", result) - } - if prefix.Active() { - t.Error("expected prefix to be inactive after cancel") - } -} - -func TestPrefixKeyInactivePassthrough(t *testing.T) { - prefix := NewPrefixHandler() - - normalKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}} - result := prefix.HandleKey(normalKey) - - if result != PrefixNone { - t.Errorf("expected none, got %d", result) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui/ -v -run TestPrefix` -Expected: FAIL — `NewPrefixHandler` not defined - -**Step 3: Implement prefix key handler** - -```go -// internal/tui/prefix.go -package tui - -import tea "github.com/charmbracelet/bubbletea" - -// Prefix key result states. -const ( - PrefixNone = iota - PrefixWaiting - PrefixComplete - PrefixCancelled -) - -// PrefixHandler implements tmux-style Ctrl+B prefix key detection. -type PrefixHandler struct { - active bool - action rune -} - -// NewPrefixHandler creates an inactive prefix handler. -func NewPrefixHandler() *PrefixHandler { - return &PrefixHandler{} -} - -// Active returns whether the prefix is waiting for a follow-up key. -func (p *PrefixHandler) Active() bool { - return p.active -} - -// Action returns the follow-up key that completed the prefix. -func (p *PrefixHandler) Action() rune { - return p.action -} - -// HandleKey processes a key event through the prefix system. -func (p *PrefixHandler) HandleKey(msg tea.KeyMsg) int { - if !p.active { - if msg.Type == tea.KeyCtrlB { - p.active = true - return PrefixWaiting - } - return PrefixNone - } - - p.active = false - - if msg.Type == tea.KeyEsc { - return PrefixCancelled - } - - if msg.Type == tea.KeyRunes && len(msg.Runes) > 0 { - p.action = msg.Runes[0] - return PrefixComplete - } - - return PrefixCancelled -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v -run TestPrefix` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/tui/prefix.go internal/tui/prefix_test.go -git commit -m "feat(tui): tmux-style Ctrl+B prefix key handler" -``` - ---- - -### Task 2: Build Context Menu Component - -**Files:** -- Create: `internal/tui/menu.go` -- Create: `internal/tui/menu_test.go` - -**Step 1: Write tests for context menu** - -```go -// internal/tui/menu_test.go -package tui - -import ( - "strings" - "testing" -) - -func TestMenuRender(t *testing.T) { - items := []MenuItem{ - {Label: "Fork Thread", Key: 'f'}, - {Label: "Delete Thread", Key: 'd'}, - {Label: "Rename Thread", Key: 'r'}, - } - menu := NewMenuModel("Thread Actions", items) - - output := menu.View() - if !strings.Contains(output, "Fork Thread") { - t.Errorf("expected Fork Thread in output:\n%s", output) - } - if !strings.Contains(output, "Delete Thread") { - t.Errorf("expected Delete Thread in output:\n%s", output) - } -} - -func TestMenuNavigation(t *testing.T) { - items := []MenuItem{ - {Label: "First", Key: 'a'}, - {Label: "Second", Key: 'b'}, - } - menu := NewMenuModel("Test", items) - - if menu.SelectedIndex() != 0 { - t.Errorf("expected 0, got %d", menu.SelectedIndex()) - } - - menu.MoveDown() - if menu.SelectedIndex() != 1 { - t.Errorf("expected 1, got %d", menu.SelectedIndex()) - } - - menu.MoveDown() - if menu.SelectedIndex() != 1 { - t.Errorf("expected clamped at 1, got %d", menu.SelectedIndex()) - } -} - -func TestMenuSelect(t *testing.T) { - items := []MenuItem{ - {Label: "Fork", Key: 'f'}, - {Label: "Delete", Key: 'd'}, - } - menu := NewMenuModel("Test", items) - - selected := menu.Selected() - if selected.Key != 'f' { - t.Errorf("expected f, got %c", selected.Key) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui/ -v -run TestMenu` -Expected: FAIL — `NewMenuModel` not defined - -**Step 3: Implement context menu** - -```go -// internal/tui/menu.go -package tui - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -var ( - menuBorderStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("39")). - Padding(1, 2) - menuTitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("39")). - MarginBottom(1) - menuItemStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("252")) - menuSelectedItemStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("39")). - Bold(true) -) - -// MenuItem is a single entry in the context menu. -type MenuItem struct { - Label string - Key rune -} - -// MenuModel is an overlay context menu. -type MenuModel struct { - title string - items []MenuItem - selected int -} - -// NewMenuModel creates a context menu. -func NewMenuModel(title string, items []MenuItem) MenuModel { - return MenuModel{ - title: title, - items: items, - } -} - -// SelectedIndex returns the current selection. -func (m *MenuModel) SelectedIndex() int { - return m.selected -} - -// Selected returns the currently highlighted menu item. -func (m *MenuModel) Selected() MenuItem { - return m.items[m.selected] -} - -// MoveDown moves selection down. -func (m *MenuModel) MoveDown() { - if m.selected < len(m.items)-1 { - m.selected++ - } -} - -// MoveUp moves selection up. -func (m *MenuModel) MoveUp() { - if m.selected > 0 { - m.selected-- - } -} - -// View renders the menu overlay. -func (m MenuModel) View() string { - title := menuTitleStyle.Render(m.title) - - var lines []string - for i, item := range m.items { - style := menuItemStyle - prefix := " " - if i == m.selected { - style = menuSelectedItemStyle - prefix = "▸ " - } - line := style.Render(fmt.Sprintf("%s[%c] %s", prefix, item.Key, item.Label)) - lines = append(lines, line) - } - - content := title + "\n" + strings.Join(lines, "\n") - return menuBorderStyle.Render(content) -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/tui/menu.go internal/tui/menu_test.go -git commit -m "feat(tui): context menu overlay component" -``` - ---- - -### Task 3: Wire Prefix + Menu into App - -**Files:** -- Modify: `internal/tui/app.go` -- Modify: `internal/tui/app_test.go` - -**Step 1: Write tests for Ctrl+B → m opening context menu** - -Add to `app_test.go`: - -```go -func TestAppCtrlBMOpensMenu(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Test") - - app := NewAppModel(store) - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - updated, _ := app.Update(ctrlB) - app = updated.(AppModel) - - mKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}} - updated, _ = app.Update(mKey) - app = updated.(AppModel) - - if !app.MenuVisible() { - t.Error("expected menu to be visible") - } -} -``` - -**Step 2: Integrate prefix handler and menu into app** - -Add `prefix *PrefixHandler`, `menu *MenuModel`, and `menuVisible bool` to AppModel. In `handleKey`, check prefix first. On `PrefixComplete` with action `'m'`, show the thread context menu. On Enter in menu, execute the selected action. On Esc in menu, close it. - -**Step 3: Run tests** - -Run: `go test ./internal/tui/ -v` -Expected: PASS - -**Step 4: Commit** - -```bash -git add internal/tui/app.go internal/tui/app_test.go -git commit -m "feat(tui): wire Ctrl+B prefix and context menu into app" -``` - ---- - -### Task 4: Implement Fork and Delete Actions - -**Files:** -- Create: `internal/tui/actions.go` -- Create: `internal/tui/actions_test.go` - -**Step 1: Write tests for action commands** - -```go -// internal/tui/actions_test.go -package tui - -import "testing" - -func TestForkActionMsg(t *testing.T) { - msg := ForkThreadMsg{ - ParentID: "t-1", - Instructions: "Continue from here", - } - if msg.ParentID != "t-1" { - t.Errorf("expected t-1, got %s", msg.ParentID) - } -} - -func TestDeleteActionMsg(t *testing.T) { - msg := DeleteThreadMsg{ThreadID: "t-1"} - if msg.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", msg.ThreadID) - } -} -``` - -**Step 2: Implement action message types** - -```go -// internal/tui/actions.go -package tui - -// ForkThreadMsg requests forking a thread. -type ForkThreadMsg struct { - ParentID string - Instructions string -} - -// DeleteThreadMsg requests deleting a thread. -type DeleteThreadMsg struct { - ThreadID string -} - -// RenameThreadMsg requests renaming a thread. -type RenameThreadMsg struct { - ThreadID string - NewTitle string -} -``` - -**Step 3: Handle action messages in App.Update** - -In `app.go`, handle `ForkThreadMsg` by calling `client.CreateThread` with a `tea.Cmd` that returns the result. Handle `DeleteThreadMsg` by calling `client.DeleteThread` and removing from store. These are async commands that return result messages. - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/tui/actions.go internal/tui/actions_test.go internal/tui/app.go -git commit -m "feat(tui): fork and delete thread actions via context menu" -``` diff --git a/docs/plans/2026-03-17-08-config-help-keybindings.md b/docs/plans/2026-03-17-08-config-help-keybindings.md deleted file mode 100644 index 06fc67d..0000000 --- a/docs/plans/2026-03-17-08-config-help-keybindings.md +++ /dev/null @@ -1,441 +0,0 @@ -# Phase 8: Config, Help & Keybindings - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add Viper-based TOML configuration, a help overlay displaying keybindings, and the cobra CLI command structure. Config controls theme, default model, and app-server binary path. - -**Architecture:** Cobra root command with `--config` flag. Viper loads `dj.toml` from the working directory, with user-level fallback at `~/.config/dj/config.toml`. Config struct is passed to AppModel on startup. Help overlay is a Bubble Tea component toggled with `?`. Keybinding display is a styled list rendered in the overlay. - -**Tech Stack:** Go, cobra, viper, TOML - -**Prerequisites:** Phase 7 (prefix keys, context menu, all TUI components) - ---- - -### Task 1: Add Cobra and Viper Dependencies - -**Files:** -- Modify: `go.mod` - -**Step 1: Add dependencies** - -```bash -go get github.com/spf13/cobra -go get github.com/spf13/viper -go mod tidy -``` - -**Step 2: Verify** - -Run: `go build ./...` -Expected: builds successfully - -**Step 3: Commit** - -```bash -git add go.mod go.sum -git commit -m "deps: add cobra and viper for CLI and config" -``` - ---- - -### Task 2: Define Config Types - -**Files:** -- Create: `internal/config/config.go` -- Create: `internal/config/config_test.go` - -**Step 1: Write tests for config loading** - -```go -// internal/config/config_test.go -package config - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadDefaults(t *testing.T) { - cfg, err := Load("") - if err != nil { - t.Fatalf("Load failed: %v", err) - } - if cfg.AppServer.Command != DefaultAppServerCommand { - t.Errorf("expected default command %s, got %s", DefaultAppServerCommand, cfg.AppServer.Command) - } -} - -func TestLoadFromFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "dj.toml") - - content := ` -[appserver] -command = "/usr/local/bin/codex" - -[ui] -theme = "dark" -` - os.WriteFile(path, []byte(content), 0644) - - cfg, err := Load(path) - if err != nil { - t.Fatalf("Load failed: %v", err) - } - if cfg.AppServer.Command != "/usr/local/bin/codex" { - t.Errorf("expected custom command, got %s", cfg.AppServer.Command) - } - if cfg.UI.Theme != "dark" { - t.Errorf("expected dark theme, got %s", cfg.UI.Theme) - } -} - -func TestLoadMissingFileUsesDefaults(t *testing.T) { - cfg, err := Load("/nonexistent/dj.toml") - if err != nil { - t.Fatalf("Load failed: %v", err) - } - if cfg.AppServer.Command != DefaultAppServerCommand { - t.Errorf("expected default command, got %s", cfg.AppServer.Command) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/config/ -v` -Expected: FAIL — package not found - -**Step 3: Implement config types and loader** - -```go -// internal/config/config.go -package config - -import ( - "github.com/spf13/viper" -) - -// Default configuration values. -const ( - DefaultAppServerCommand = "codex" - DefaultTheme = "default" -) - -// Config is the top-level DJ configuration. -type Config struct { - AppServer AppServerConfig - UI UIConfig -} - -// AppServerConfig controls the app-server connection. -type AppServerConfig struct { - Command string - Args []string -} - -// UIConfig controls UI appearance. -type UIConfig struct { - Theme string -} - -// Load reads configuration from the given path, falling back to defaults. -func Load(path string) (*Config, error) { - v := viper.New() - v.SetConfigType("toml") - - v.SetDefault("appserver.command", DefaultAppServerCommand) - v.SetDefault("appserver.args", []string{"app-server", "--listen", "stdio://"}) - v.SetDefault("ui.theme", DefaultTheme) - - if path != "" { - v.SetConfigFile(path) - if err := v.ReadInConfig(); err != nil { - if _, ok := err.(viper.ConfigFileNotFoundError); !ok { - // File exists but can't be read — use defaults - } - } - } - - cfg := &Config{ - AppServer: AppServerConfig{ - Command: v.GetString("appserver.command"), - Args: v.GetStringSlice("appserver.args"), - }, - UI: UIConfig{ - Theme: v.GetString("ui.theme"), - }, - } - - return cfg, nil -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/config/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/config/config.go internal/config/config_test.go -git commit -m "feat(config): Viper-based TOML config with defaults" -``` - ---- - -### Task 3: Build Help Overlay - -**Files:** -- Create: `internal/tui/help.go` -- Create: `internal/tui/help_test.go` - -**Step 1: Write tests for help overlay** - -```go -// internal/tui/help_test.go -package tui - -import ( - "strings" - "testing" -) - -func TestHelpRender(t *testing.T) { - help := NewHelpModel() - output := help.View() - - expectedBindings := []string{"←/→", "↑/↓", "Enter", "Esc", "Ctrl+B", "?", "Ctrl+C"} - for _, binding := range expectedBindings { - if !strings.Contains(output, binding) { - t.Errorf("expected %q in help output:\n%s", binding, output) - } - } -} - -func TestHelpContainsActions(t *testing.T) { - help := NewHelpModel() - output := help.View() - - expectedActions := []string{"Navigate", "Open session", "Back", "Menu", "Help", "Quit"} - for _, action := range expectedActions { - if !strings.Contains(output, action) { - t.Errorf("expected %q in help output:\n%s", action, output) - } - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui/ -v -run TestHelp` -Expected: FAIL — `NewHelpModel` not defined - -**Step 3: Implement help overlay** - -```go -// internal/tui/help.go -package tui - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -var ( - helpBorderStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("39")). - Padding(1, 2) - helpTitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("39")). - MarginBottom(1) - helpKeyStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("42")). - Width(12) - helpDescStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("252")) -) - -type keybinding struct { - key string - description string -} - -var keybindings = []keybinding{ - {"←/→", "Navigate cards horizontally"}, - {"↑/↓", "Navigate cards vertically"}, - {"Enter", "Open session pane"}, - {"Esc", "Back / close overlay"}, - {"t", "Toggle tree view"}, - {"n", "New thread"}, - {"Ctrl+B", "Prefix key (tmux-style)"}, - {"Ctrl+B m", "Open context menu (Menu)"}, - {"?", "Toggle help (Help)"}, - {"Ctrl+C", "Quit"}, -} - -// HelpModel displays the keybinding reference overlay. -type HelpModel struct{} - -// NewHelpModel creates a help overlay. -func NewHelpModel() HelpModel { - return HelpModel{} -} - -// View renders the help overlay. -func (h HelpModel) View() string { - title := helpTitleStyle.Render("Keybindings") - - var lines []string - for _, kb := range keybindings { - key := helpKeyStyle.Render(kb.key) - desc := helpDescStyle.Render(kb.description) - lines = append(lines, fmt.Sprintf("%s %s", key, desc)) - } - - content := title + "\n" + strings.Join(lines, "\n") - return helpBorderStyle.Render(content) -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/tui/help.go internal/tui/help_test.go -git commit -m "feat(tui): help overlay with keybinding reference" -``` - ---- - -### Task 4: Build Cobra Root Command - -**Files:** -- Modify: `cmd/dj/main.go` - -**Step 1: Rewrite main.go with cobra command** - -```go -// cmd/dj/main.go -package main - -import ( - "fmt" - "os" - - tea "github.com/charmbracelet/bubbletea" - "github.com/spf13/cobra" - - "github.com/robinojw/dj/internal/config" - "github.com/robinojw/dj/internal/state" - "github.com/robinojw/dj/internal/tui" -) - -var configPath string - -var rootCmd = &cobra.Command{ - Use: "dj", - Short: "DJ — Codex TUI Visualizer", - RunE: runApp, -} - -func init() { - rootCmd.Flags().StringVar(&configPath, "config", "", "path to dj.toml config file") -} - -func main() { - if err := rootCmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) - } -} - -func runApp(cmd *cobra.Command, args []string) error { - cfg, err := config.Load(configPath) - if err != nil { - return fmt.Errorf("load config: %w", err) - } - - _ = cfg // used in later phases for app-server connection - - store := state.NewThreadStore() - app := tui.NewAppModel(store) - - program := tea.NewProgram(app, tea.WithAltScreen()) - _, err = program.Run() - return err -} -``` - -**Step 2: Verify it builds** - -Run: `go build ./cmd/dj && ./dj --help` -Expected: Shows usage with `--config` flag - -**Step 3: Commit** - -```bash -git add cmd/dj/main.go -git commit -m "feat: cobra root command with --config flag" -``` - ---- - -### Task 5: Wire Help Toggle into App - -**Files:** -- Modify: `internal/tui/app.go` -- Modify: `internal/tui/app_test.go` - -**Step 1: Write test for ? toggling help** - -Add to `app_test.go`: - -```go -func TestAppHelpToggle(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - helpKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'?'}} - updated, _ := app.Update(helpKey) - appModel := updated.(AppModel) - - if !appModel.HelpVisible() { - t.Error("expected help to be visible") - } - - updated, _ = appModel.Update(helpKey) - appModel = updated.(AppModel) - - if appModel.HelpVisible() { - t.Error("expected help to be hidden") - } -} -``` - -**Step 2: Add help toggle to App** - -Add `help HelpModel`, `helpVisible bool` fields. Add `HelpVisible() bool` method. In `handleKey`, `?` toggles `helpVisible`. In `View()`, render help overlay on top when visible. - -**Step 3: Run tests** - -Run: `go test ./internal/tui/ -v` -Expected: PASS - -**Step 4: Commit** - -```bash -git add internal/tui/app.go internal/tui/app_test.go -git commit -m "feat(tui): ? toggles help overlay" -``` diff --git a/docs/plans/2026-03-17-09-polish-extensibility.md b/docs/plans/2026-03-17-09-polish-extensibility.md deleted file mode 100644 index db02556..0000000 --- a/docs/plans/2026-03-17-09-polish-extensibility.md +++ /dev/null @@ -1,417 +0,0 @@ -# Phase 9: Polish & Extensibility - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add error handling for app-server disconnection, status bar with connection state, graceful shutdown, and wire everything end-to-end so `./dj` spawns the app-server, performs the handshake, and renders live threads. - -**Architecture:** The App model gains lifecycle management: spawn app-server on Init, run ReadLoop in a goroutine, handle disconnection gracefully. A status bar component shows connection state and selected thread info. Error messages are displayed inline. The `n` key creates a new thread via the API. - -**Tech Stack:** Go, Bubble Tea, Lipgloss - -**Prerequisites:** Phase 8 (config, cobra CLI, all TUI components) - ---- - -### Task 1: Build Status Bar Component - -**Files:** -- Create: `internal/tui/statusbar.go` -- Create: `internal/tui/statusbar_test.go` - -**Step 1: Write tests for status bar** - -```go -// internal/tui/statusbar_test.go -package tui - -import ( - "strings" - "testing" -) - -func TestStatusBarConnected(t *testing.T) { - bar := NewStatusBar() - bar.SetConnected(true) - bar.SetThreadCount(3) - bar.SetSelectedThread("Build web app") - - output := bar.View() - - if !strings.Contains(output, "Connected") { - t.Errorf("expected Connected in output:\n%s", output) - } - if !strings.Contains(output, "3 threads") { - t.Errorf("expected thread count in output:\n%s", output) - } - if !strings.Contains(output, "Build web app") { - t.Errorf("expected selected thread in output:\n%s", output) - } -} - -func TestStatusBarDisconnected(t *testing.T) { - bar := NewStatusBar() - bar.SetConnected(false) - - output := bar.View() - - if !strings.Contains(output, "Disconnected") { - t.Errorf("expected Disconnected in output:\n%s", output) - } -} - -func TestStatusBarError(t *testing.T) { - bar := NewStatusBar() - bar.SetError("connection lost") - - output := bar.View() - - if !strings.Contains(output, "connection lost") { - t.Errorf("expected error in output:\n%s", output) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui/ -v -run TestStatusBar` -Expected: FAIL — `NewStatusBar` not defined - -**Step 3: Implement status bar** - -```go -// internal/tui/statusbar.go -package tui - -import ( - "fmt" - - "github.com/charmbracelet/lipgloss" -) - -var ( - statusBarStyle = lipgloss.NewStyle(). - Background(lipgloss.Color("236")). - Foreground(lipgloss.Color("252")). - Padding(0, 1) - statusConnectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("42")) - statusDisconnectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("196")) - statusErrorStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("196")). - Bold(true) -) - -// StatusBar displays connection state and context info. -type StatusBar struct { - connected bool - threadCount int - selectedThread string - errorMessage string - width int -} - -// NewStatusBar creates a status bar. -func NewStatusBar() *StatusBar { - return &StatusBar{} -} - -// SetConnected updates the connection state. -func (s *StatusBar) SetConnected(connected bool) { - s.connected = connected - if connected { - s.errorMessage = "" - } -} - -// SetThreadCount updates the thread count display. -func (s *StatusBar) SetThreadCount(count int) { - s.threadCount = count -} - -// SetSelectedThread updates the selected thread name. -func (s *StatusBar) SetSelectedThread(name string) { - s.selectedThread = name -} - -// SetError sets an error message. -func (s *StatusBar) SetError(msg string) { - s.errorMessage = msg -} - -// SetWidth sets the status bar width. -func (s *StatusBar) SetWidth(width int) { - s.width = width -} - -// View renders the status bar. -func (s StatusBar) View() string { - var left string - if s.connected { - left = statusConnectedStyle.Render("● Connected") - } else { - left = statusDisconnectedStyle.Render("○ Disconnected") - } - - if s.errorMessage != "" { - left += " " + statusErrorStyle.Render(s.errorMessage) - } - - middle := "" - if s.threadCount > 0 { - middle = fmt.Sprintf(" | %d threads", s.threadCount) - } - - right := "" - if s.selectedThread != "" { - right = fmt.Sprintf(" | %s", s.selectedThread) - } - - content := left + middle + right - style := statusBarStyle.Width(s.width) - return style.Render(content) -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/tui/ -v -run TestStatusBar` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/tui/statusbar.go internal/tui/statusbar_test.go -git commit -m "feat(tui): status bar with connection state" -``` - ---- - -### Task 2: Add App-Server Lifecycle to App Model - -**Files:** -- Modify: `internal/tui/app.go` -- Modify: `cmd/dj/main.go` - -**Step 1: Wire app-server spawn into Init** - -Update `AppModel` to accept an `*appserver.Client` and `*config.Config`. The `Init()` command should: -1. Spawn the app-server process -2. Start the ReadLoop goroutine -3. Perform the Initialize handshake -4. Return the server capabilities as a message - -```go -// AppServerConnectedMsg is sent after successful handshake. -type AppServerConnectedMsg struct { - ServerName string - ServerVersion string -} -``` - -In `Init()`: - -```go -func (m AppModel) Init() tea.Cmd { - return func() tea.Msg { - ctx := context.Background() - if err := m.client.Start(ctx); err != nil { - return AppServerErrorMsg{Err: err} - } - - router := appserver.NewNotificationRouter() - m.client.Router = router - WireEventBridge(router, m.program) - - go m.client.ReadLoop(m.client.Dispatch) - - caps, err := m.client.Initialize(ctx) - if err != nil { - return AppServerErrorMsg{Err: err} - } - - return AppServerConnectedMsg{ - ServerName: caps.ServerInfo.Name, - ServerVersion: caps.ServerInfo.Version, - } - } -} -``` - -**Step 2: Handle connection messages in Update** - -In `Update`, handle `AppServerConnectedMsg` to update the status bar and `AppServerErrorMsg` to display errors. - -**Step 3: Update main.go to pass client and config** - -```go -func runApp(cmd *cobra.Command, args []string) error { - cfg, err := config.Load(configPath) - if err != nil { - return fmt.Errorf("load config: %w", err) - } - - client := appserver.NewClient(cfg.AppServer.Command, cfg.AppServer.Args...) - store := state.NewThreadStore() - app := tui.NewAppModel(store, client) - - program := tea.NewProgram(app, tea.WithAltScreen()) - app.SetProgram(program) - - _, err = program.Run() - - client.Stop() - return err -} -``` - -**Step 4: Verify it builds** - -Run: `go build ./cmd/dj && echo "Build OK"` -Expected: Build OK - -**Step 5: Commit** - -```bash -git add internal/tui/app.go cmd/dj/main.go -git commit -m "feat: wire app-server lifecycle into TUI startup" -``` - ---- - -### Task 3: Add New Thread Creation - -**Files:** -- Modify: `internal/tui/app.go` -- Modify: `internal/tui/app_test.go` - -**Step 1: Write test for 'n' key creating thread** - -Add to `app_test.go`: - -```go -func TestAppNewThread(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} - _, cmd := app.Update(nKey) - - if cmd == nil { - t.Error("expected command for thread creation") - } -} -``` - -**Step 2: Implement** - -In `handleKey`, when `'n'` is pressed, return a `tea.Cmd` that calls `client.CreateThread()` and returns a `ThreadCreatedMsg`. In `Update`, handle `ThreadCreatedMsg` by adding the thread to the store. - -**Step 3: Run tests** - -Run: `go test ./internal/tui/ -v` -Expected: PASS - -**Step 4: Commit** - -```bash -git add internal/tui/app.go internal/tui/app_test.go -git commit -m "feat(tui): 'n' key creates new thread via app-server" -``` - ---- - -### Task 4: Graceful Shutdown - -**Files:** -- Modify: `internal/tui/app.go` - -**Step 1: Add cleanup on quit** - -When Ctrl+C or quit is triggered, stop the app-server client before exiting. Use a `tea.Sequence` or handle cleanup in `main.go` after `program.Run()` returns. - -The app-server `Stop()` is already called in `main.go`'s defer. Verify the ReadLoop goroutine exits cleanly when stdin is closed (it already does — scanner returns false on EOF). - -**Step 2: Verify clean shutdown** - -Run: `go build ./cmd/dj && echo "Build OK"` -Expected: Build OK. Manual test: launch, Ctrl+C exits cleanly. - -**Step 3: Commit** - -```bash -git add internal/tui/app.go -git commit -m "feat: graceful shutdown with app-server cleanup" -``` - ---- - -### Task 5: End-to-End Integration Test - -**Files:** -- Create: `internal/tui/integration_test.go` - -**Step 1: Write build-tagged integration test** - -```go -//go:build integration - -package tui - -import ( - "context" - "testing" - "time" - - "github.com/robinojw/dj/internal/appserver" - "github.com/robinojw/dj/internal/state" -) - -func TestIntegrationEndToEnd(t *testing.T) { - client := appserver.NewClient("codex", "app-server", "--listen", "stdio://") - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - if err := client.Start(ctx); err != nil { - t.Fatalf("Start failed: %v", err) - } - defer client.Stop() - - router := appserver.NewNotificationRouter() - client.Router = router - go client.ReadLoop(client.Dispatch) - - caps, err := client.Initialize(ctx) - if err != nil { - t.Fatalf("Initialize failed: %v", err) - } - t.Logf("Connected: %s %s", caps.ServerInfo.Name, caps.ServerInfo.Version) - - store := state.NewThreadStore() - - result, err := client.CreateThread(ctx, "Say hello") - if err != nil { - t.Fatalf("CreateThread failed: %v", err) - } - store.Add(result.ThreadID, "Say hello") - t.Logf("Created thread: %s", result.ThreadID) - - threads := store.All() - if len(threads) != 1 { - t.Fatalf("expected 1 thread, got %d", len(threads)) - } -} -``` - -**Step 2: Verify it compiles** - -Run: `go vet -tags=integration ./internal/tui/` -Expected: no errors - -**Step 3: Commit** - -```bash -git add internal/tui/integration_test.go -git commit -m "test(tui): end-to-end integration test with real app-server" -``` diff --git a/docs/plans/2026-03-18-session-card-activity-design.md b/docs/plans/2026-03-18-session-card-activity-design.md deleted file mode 100644 index f3d2159..0000000 --- a/docs/plans/2026-03-18-session-card-activity-design.md +++ /dev/null @@ -1,50 +0,0 @@ -# Session Card Activity Indicators - -## Problem - -Session cards show only a static status word (idle/active/completed/error). Users cannot tell what the CLI is actually doing — whether it's thinking, running a command, applying a patch, or streaming a response. - -## Design - -An `Activity` field on `ThreadState` tracks the current action for each session. The card renders activity instead of the status word when present. When there is no activity, the card falls back to the existing status display. - -## Activity Mapping - -| Protocol Event | Activity Text | Behavior | -|---|---|---| -| `task_started` | `Thinking...` | Set on task start | -| `agent_reasoning_delta` | `Thinking...` | Set during reasoning | -| `agent_message_delta` | Truncated snippet of streaming text | Updated with each delta | -| `exec_command_request` | `Running: ` | Truncated to card width | -| `patch_apply_request` | `Applying patch...` | Set on patch request | -| `agent_message` (completed) | Clears activity | Falls back to status | -| `task_complete` | Clears activity | Falls back to status | - -## Card Rendering - -Activity replaces the status line when present: - -``` -Active with activity: -┌──────────────────────────────┐ -│ o3-mini │ -│ Running: git status │ -└──────────────────────────────┘ - -Idle (no activity): -┌──────────────────────────────┐ -│ o3-mini │ -│ idle │ -└──────────────────────────────┘ -``` - -Activity text uses the same color as the current status. - -## Changes - -- `state/thread.go`: Add `Activity` field, `SetActivity()`, `ClearActivity()` methods -- `state/store.go`: Add `UpdateActivity(id, activity)` method (thread-safe) -- `tui/card.go`: Prefer `thread.Activity` over `thread.Status` for card second line -- `tui/app_proto.go`: Set activity in event handlers -- `tui/bridge.go`: Handle `agent_reasoning_delta` events -- `tui/msgs.go`: Add `AgentReasoningDeltaMsg` type diff --git a/docs/plans/2026-03-18-session-card-activity-plan.md b/docs/plans/2026-03-18-session-card-activity-plan.md deleted file mode 100644 index 7353aba..0000000 --- a/docs/plans/2026-03-18-session-card-activity-plan.md +++ /dev/null @@ -1,551 +0,0 @@ -# Session Card Activity Indicators — Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Show real-time activity on session cards (e.g. "Running: git status", "Thinking...", streaming text snippets) by adding an `Activity` field to `ThreadState` and rendering it on cards in place of the status word. - -**Architecture:** Protocol event handlers set an `Activity` string on the thread via the store. The card's `View()` method checks `Activity` first — if non-empty, renders it with status color instead of the status word. Activity is cleared on `agent_message` (completed) and `task_complete`. - -**Tech Stack:** Go, Bubble Tea, Lipgloss, standard `testing` package. - ---- - -### Task 1: Add Activity field and methods to ThreadState - -**Files:** -- Modify: `internal/state/thread.go` -- Modify: `internal/state/thread_test.go` - -**Step 1: Write the failing tests** - -Add to `internal/state/thread_test.go`: - -```go -func TestThreadStateSetActivity(t *testing.T) { - thread := NewThreadState("t-1", "Test") - thread.SetActivity("Running: git status") - - if thread.Activity != "Running: git status" { - t.Errorf("expected Running: git status, got %s", thread.Activity) - } -} - -func TestThreadStateClearActivity(t *testing.T) { - thread := NewThreadState("t-1", "Test") - thread.SetActivity("Thinking...") - thread.ClearActivity() - - if thread.Activity != "" { - t.Errorf("expected empty activity, got %s", thread.Activity) - } -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `go test ./internal/state -run TestThreadState(Set|Clear)Activity -v` -Expected: FAIL — `thread.Activity undefined`, `thread.SetActivity undefined` - -**Step 3: Write minimal implementation** - -Add `Activity` field to `ThreadState` struct and two methods in `internal/state/thread.go`: - -```go -type ThreadState struct { - ID string - Title string - Status string - Activity string - ParentID string - Messages []ChatMessage - CommandOutput map[string]string -} - -func (threadState *ThreadState) SetActivity(activity string) { - threadState.Activity = activity -} - -func (threadState *ThreadState) ClearActivity() { - threadState.Activity = "" -} -``` - -**Step 4: Run tests to verify they pass** - -Run: `go test ./internal/state -run TestThreadState(Set|Clear)Activity -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add internal/state/thread.go internal/state/thread_test.go -git commit -m "feat: add Activity field to ThreadState" -``` - ---- - -### Task 2: Add UpdateActivity to ThreadStore - -**Files:** -- Modify: `internal/state/store.go` -- Modify: `internal/state/store_test.go` - -**Step 1: Write the failing tests** - -Add to `internal/state/store_test.go`: - -```go -func TestStoreUpdateActivity(t *testing.T) { - store := NewThreadStore() - store.Add("t-1", "Test") - store.UpdateActivity("t-1", "Thinking...") - - thread, _ := store.Get("t-1") - if thread.Activity != "Thinking..." { - t.Errorf("expected Thinking..., got %s", thread.Activity) - } -} - -func TestStoreUpdateActivityMissing(t *testing.T) { - store := NewThreadStore() - store.UpdateActivity("missing", "Thinking...") -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `go test ./internal/state -run TestStoreUpdateActivity -v` -Expected: FAIL — `store.UpdateActivity undefined` - -**Step 3: Write minimal implementation** - -Add to `internal/state/store.go`: - -```go -func (store *ThreadStore) UpdateActivity(id string, activity string) { - store.mu.Lock() - defer store.mu.Unlock() - - thread, exists := store.threads[id] - if !exists { - return - } - thread.Activity = activity -} -``` - -**Step 4: Run tests to verify they pass** - -Run: `go test ./internal/state -run TestStoreUpdateActivity -v` -Expected: PASS - -**Step 5: Run full state package tests** - -Run: `go test ./internal/state -v` -Expected: All PASS - -**Step 6: Commit** - -```bash -git add internal/state/store.go internal/state/store_test.go -git commit -m "feat: add UpdateActivity to ThreadStore" -``` - ---- - -### Task 3: Render activity on session cards - -**Files:** -- Modify: `internal/tui/card.go` -- Modify: `internal/tui/card_test.go` - -**Step 1: Write the failing tests** - -Add to `internal/tui/card_test.go`: - -```go -func TestCardRenderShowsActivity(t *testing.T) { - thread := state.NewThreadState("t-1", "o3-mini") - thread.Status = state.StatusActive - thread.Activity = "Running: git status" - - card := NewCardModel(thread, false) - output := card.View() - - if !strings.Contains(output, "Running: git status") { - t.Errorf("expected activity in output, got:\n%s", output) - } -} - -func TestCardRenderFallsBackToStatus(t *testing.T) { - thread := state.NewThreadState("t-1", "o3-mini") - thread.Status = state.StatusIdle - - card := NewCardModel(thread, false) - output := card.View() - - hasActivity := strings.Contains(output, "Running") - if hasActivity { - t.Errorf("expected no activity when idle, got:\n%s", output) - } - if !strings.Contains(output, "idle") { - t.Errorf("expected status fallback, got:\n%s", output) - } -} - -func TestCardRenderActivityTruncated(t *testing.T) { - thread := state.NewThreadState("t-1", "o3-mini") - thread.Status = state.StatusActive - thread.Activity = "Running: npm install --save-dev @types/react @types/react-dom" - - card := NewCardModel(thread, false) - card.SetSize(minCardWidth, minCardHeight) - output := card.View() - - if !strings.Contains(output, "...") { - t.Errorf("expected truncated activity, got:\n%s", output) - } -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `go test ./internal/tui -run TestCardRender(ShowsActivity|FallsBack|ActivityTruncated) -v` -Expected: FAIL — activity text not present in output - -**Step 3: Write minimal implementation** - -Modify `card.View()` in `internal/tui/card.go`. Replace the `statusLine` logic with an activity-aware check: - -```go -func (card CardModel) View() string { - statusColor, exists := statusColors[card.thread.Status] - if !exists { - statusColor = defaultStatusColor - } - - secondLine := card.thread.Status - hasActivity := card.thread.Activity != "" - if hasActivity { - secondLine = card.thread.Activity - } - - styledSecondLine := lipgloss.NewStyle(). - Foreground(statusColor). - Render(truncate(secondLine, card.width-cardBorderPadding)) - - titleMaxLen := card.width - cardBorderPadding - title := truncate(card.thread.Title, titleMaxLen) - content := fmt.Sprintf("%s\n%s", title, styledSecondLine) - - style := lipgloss.NewStyle(). - Width(card.width). - Height(card.height). - Border(lipgloss.RoundedBorder()). - Padding(0, 1) - - if card.selected { - style = style. - Border(lipgloss.DoubleBorder()). - BorderForeground(lipgloss.Color("39")) - } - - return style.Render(content) -} -``` - -Note: This removes the pinned indicator rendering since the canvas does not use it (the session panel handles pinned display separately via the divider bar). - -**Step 4: Run tests to verify they pass** - -Run: `go test ./internal/tui -run TestCardRender -v` -Expected: All PASS (including existing `TestCardRenderShowsTitle`, `TestCardRenderShowsStatus`, `TestCardRenderSelectedHighlight`, `TestCardDynamicSize`) - -**Step 5: Commit** - -```bash -git add internal/tui/card.go internal/tui/card_test.go -git commit -m "feat: render activity on session cards" -``` - ---- - -### Task 4: Add AgentReasoningDeltaMsg to bridge - -**Files:** -- Modify: `internal/tui/msgs.go` -- Modify: `internal/tui/bridge.go` -- Modify: `internal/tui/bridge_test.go` - -**Step 1: Write the failing test** - -Add to `internal/tui/bridge_test.go`: - -```go -func TestBridgeAgentReasoningDelta(t *testing.T) { - event := appserver.ProtoEvent{ - Msg: json.RawMessage(`{"type":"agent_reasoning_delta","delta":"Let me think..."}`), - } - msg := ProtoEventToMsg(event) - reasoning, ok := msg.(AgentReasoningDeltaMsg) - if !ok { - t.Fatalf("expected AgentReasoningDeltaMsg, got %T", msg) - } - if reasoning.Delta != "Let me think..." { - t.Errorf("expected Let me think..., got %s", reasoning.Delta) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui -run TestBridgeAgentReasoningDelta -v` -Expected: FAIL — `AgentReasoningDeltaMsg` undefined - -**Step 3: Write minimal implementation** - -Add to `internal/tui/msgs.go`: - -```go -type AgentReasoningDeltaMsg struct { - Delta string -} -``` - -Add case to switch in `ProtoEventToMsg` in `internal/tui/bridge.go`: - -```go -case appserver.EventAgentReasonDelta: - return decodeReasoningDelta(event.Msg) -``` - -Add decoder function to `internal/tui/bridge.go`: - -```go -func decodeReasoningDelta(raw json.RawMessage) tea.Msg { - var delta appserver.AgentDelta - if err := json.Unmarshal(raw, &delta); err != nil { - return nil - } - return AgentReasoningDeltaMsg{Delta: delta.Delta} -} -``` - -Note: Reuses `appserver.AgentDelta` struct since reasoning deltas have the same `{"delta":"..."}` JSON shape. - -**Step 4: Run tests to verify they pass** - -Run: `go test ./internal/tui -run TestBridge -v` -Expected: All PASS - -**Step 5: Commit** - -```bash -git add internal/tui/msgs.go internal/tui/bridge.go internal/tui/bridge_test.go -git commit -m "feat: decode agent_reasoning_delta events in bridge" -``` - ---- - -### Task 5: Wire event handlers to set activity - -**Files:** -- Modify: `internal/tui/app_proto.go` -- Modify: `internal/tui/app.go` (add Update case for new msg type) - -**Step 1: Check current Update switch for msg routing** - -Read `internal/tui/app.go` to find the `Update` method's switch statement and identify where to add the new `AgentReasoningDeltaMsg` case. - -**Step 2: Update handleTaskStarted to set activity** - -In `internal/tui/app_proto.go`, modify `handleTaskStarted`: - -```go -func (app AppModel) handleTaskStarted() (tea.Model, tea.Cmd) { - if app.sessionID == "" { - return app, nil - } - app.store.UpdateStatus(app.sessionID, state.StatusActive, "") - app.store.UpdateActivity(app.sessionID, "Thinking...") - messageID := fmt.Sprintf("msg-%d", time.Now().UnixNano()) - app.currentMessageID = messageID - thread, exists := app.store.Get(app.sessionID) - if exists { - thread.AppendMessage(state.ChatMessage{ - ID: messageID, - Role: "assistant", - Content: "", - }) - } - return app, nil -} -``` - -**Step 3: Update handleAgentDelta to set activity snippet** - -In `internal/tui/app_proto.go`, modify `handleAgentDelta`. After appending the delta, update activity with a snippet of the latest message content: - -```go -func (app AppModel) handleAgentDelta(msg AgentDeltaMsg) (tea.Model, tea.Cmd) { - noSession := app.sessionID == "" - noMessage := app.currentMessageID == "" - if noSession || noMessage { - return app, nil - } - thread, exists := app.store.Get(app.sessionID) - if !exists { - return app, nil - } - thread.AppendDelta(app.currentMessageID, msg.Delta) - snippet := latestMessageSnippet(thread, app.currentMessageID) - app.store.UpdateActivity(app.sessionID, snippet) - return app, nil -} -``` - -**Step 4: Add latestMessageSnippet helper** - -Add to `internal/tui/app_proto.go`: - -```go -const activitySnippetMaxLen = 40 - -func latestMessageSnippet(thread *state.ThreadState, messageID string) string { - for index := range thread.Messages { - if thread.Messages[index].ID != messageID { - continue - } - content := thread.Messages[index].Content - if len(content) <= activitySnippetMaxLen { - return content - } - return content[len(content)-activitySnippetMaxLen:] - } - return "" -} -``` - -**Step 5: Add handleReasoningDelta handler** - -Add to `internal/tui/app_proto.go`: - -```go -func (app AppModel) handleReasoningDelta() (tea.Model, tea.Cmd) { - if app.sessionID != "" { - app.store.UpdateActivity(app.sessionID, "Thinking...") - } - return app, nil -} -``` - -**Step 6: Update handleExecApproval to set activity** - -In `internal/tui/app_proto.go`, modify `handleExecApproval`: - -```go -func (app AppModel) handleExecApproval(msg ExecApprovalRequestMsg) (tea.Model, tea.Cmd) { - if app.sessionID != "" { - activity := fmt.Sprintf("Running: %s", msg.Command) - app.store.UpdateActivity(app.sessionID, activity) - } - if app.client != nil { - app.client.SendApproval(msg.EventID, appserver.OpExecApproval, true) - } - return app, nil -} -``` - -**Step 7: Update handlePatchApproval to set activity** - -In `internal/tui/app_proto.go`, modify `handlePatchApproval`: - -```go -func (app AppModel) handlePatchApproval(msg PatchApprovalRequestMsg) (tea.Model, tea.Cmd) { - if app.sessionID != "" { - app.store.UpdateActivity(app.sessionID, "Applying patch...") - } - if app.client != nil { - app.client.SendApproval(msg.EventID, appserver.OpPatchApproval, true) - } - return app, nil -} -``` - -**Step 8: Update handleAgentMessageCompleted to clear activity** - -In `internal/tui/app_proto.go`, modify `handleAgentMessageCompleted`: - -```go -func (app AppModel) handleAgentMessageCompleted() (tea.Model, tea.Cmd) { - app.currentMessageID = "" - if app.sessionID != "" { - app.store.UpdateActivity(app.sessionID, "") - } - return app, nil -} -``` - -**Step 9: Update handleTaskComplete to clear activity** - -In `internal/tui/app_proto.go`, modify `handleTaskComplete`: - -```go -func (app AppModel) handleTaskComplete() (tea.Model, tea.Cmd) { - if app.sessionID != "" { - app.store.UpdateStatus(app.sessionID, state.StatusCompleted, "") - app.store.UpdateActivity(app.sessionID, "") - } - app.currentMessageID = "" - return app, nil -} -``` - -**Step 10: Add AgentReasoningDeltaMsg case to Update in app.go** - -Find the switch statement in `app.go`'s `Update` method and add: - -```go -case AgentReasoningDeltaMsg: - return app.handleReasoningDelta() -``` - -**Step 11: Run all tests** - -Run: `go test ./... -v -race` -Expected: All PASS - -**Step 12: Run linter** - -Run: `golangci-lint run` -Expected: No issues - -**Step 13: Commit** - -```bash -git add internal/tui/app_proto.go internal/tui/app.go -git commit -m "feat: wire protocol events to session card activity" -``` - ---- - -### Task 6: Verify build and full test suite - -**Step 1: Build** - -Run: `go build -o dj ./cmd/dj` -Expected: Build succeeds - -**Step 2: Full test suite with race detector** - -Run: `go test ./... -v -race` -Expected: All PASS - -**Step 3: Lint** - -Run: `golangci-lint run` -Expected: No issues - -**Step 4: Final commit if any cleanup needed** - -Only if lint or tests revealed issues to fix. diff --git a/docs/plans/2026-03-18-session-scrollback-design.md b/docs/plans/2026-03-18-session-scrollback-design.md deleted file mode 100644 index 969d6fd..0000000 --- a/docs/plans/2026-03-18-session-scrollback-design.md +++ /dev/null @@ -1,60 +0,0 @@ -# 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 diff --git a/docs/plans/2026-03-18-session-scrollback-plan.md b/docs/plans/2026-03-18-session-scrollback-plan.md deleted file mode 100644 index f8587f7..0000000 --- a/docs/plans/2026-03-18-session-scrollback-plan.md +++ /dev/null @@ -1,683 +0,0 @@ -# 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" -``` diff --git a/docs/plans/2026-03-18-split-layout-pinned-sessions.md b/docs/plans/2026-03-18-split-layout-pinned-sessions.md deleted file mode 100644 index 2b73204..0000000 --- a/docs/plans/2026-03-18-split-layout-pinned-sessions.md +++ /dev/null @@ -1,2019 +0,0 @@ -# Split Layout & Pinned Sessions Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Replace the current single-session fullscreen view with a persistent top/bottom split layout where multiple sessions can be pinned side-by-side in a bottom panel, with a focus state machine routing keys between canvas and session panes. - -**Architecture:** The `AppModel` gains an ordered `pinnedSessions []string` slice and a `FocusPane` enum replacing the current `focus int`. The `View()` always renders canvas on top, and when sessions are pinned, renders a divider + horizontal session panel below. A `SessionPanelModel` sub-model owns the panel state to keep `AppModel` lean. PTY resize is triggered whenever the pinned set or terminal size changes. - -**Tech Stack:** Go, Bubble Tea, Lipgloss, creack/pty, charmbracelet/x/vt - ---- - -## Task 1: Add new message types for pin/unpin/focus - -New messages that the rest of the system will produce and `Update()` will consume. - -**Files:** -- Modify: `internal/tui/msgs.go` -- Test: `internal/tui/msgs_test.go` - -**Step 1: Write the failing test** - -Add to `internal/tui/msgs_test.go`: - -```go -func TestPinUnpinMessages(t *testing.T) { - pinMsg := PinSessionMsg{ThreadID: "t-1"} - if pinMsg.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", pinMsg.ThreadID) - } - - unpinMsg := UnpinSessionMsg{ThreadID: "t-2"} - if unpinMsg.ThreadID != "t-2" { - t.Errorf("expected t-2, got %s", unpinMsg.ThreadID) - } - - focusMsg := FocusSessionPaneMsg{Index: 2} - if focusMsg.Index != 2 { - t.Errorf("expected 2, got %d", focusMsg.Index) - } - - switchMsg := SwitchPaneFocusMsg{Pane: FocusPaneSession} - if switchMsg.Pane != FocusPaneSession { - t.Errorf("expected FocusPaneSession, got %d", switchMsg.Pane) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui -run TestPinUnpinMessages -v` -Expected: FAIL — `PinSessionMsg`, `UnpinSessionMsg`, `FocusSessionPaneMsg`, `SwitchPaneFocusMsg`, `FocusPaneSession` undefined - -**Step 3: Write minimal implementation** - -Add to `internal/tui/msgs.go` (after the existing `PTYOutputMsg`): - -```go -type FocusPane int - -const ( - FocusPaneCanvas FocusPane = iota - FocusPaneSession -) - -type PinSessionMsg struct { - ThreadID string -} - -type UnpinSessionMsg struct { - ThreadID string -} - -type FocusSessionPaneMsg struct { - Index int -} - -type SwitchPaneFocusMsg struct { - Pane FocusPane -} -``` - -**Step 4: Run test to verify it passes** - -Run: `go test ./internal/tui -run TestPinUnpinMessages -v` -Expected: PASS - -**Step 5: Run full test suite** - -Run: `go test ./... -v -race` -Expected: All 135 tests pass - -**Step 6: Commit** - -```bash -git add internal/tui/msgs.go internal/tui/msgs_test.go -git commit -m "feat(tui): add pin/unpin/focus message types for session panel" -``` - ---- - -## Task 2: Create SessionPanelModel sub-model - -Extracts panel state from AppModel into its own sub-model. This is where `pinnedSessions`, `activePaneIdx`, and `splitRatio` live. - -**Files:** -- Create: `internal/tui/session_panel.go` -- Create: `internal/tui/session_panel_test.go` - -**Step 1: Write the failing test** - -Create `internal/tui/session_panel_test.go`: - -```go -package tui - -import "testing" - -func TestSessionPanelPinAddsThread(t *testing.T) { - panel := NewSessionPanelModel() - panel.Pin("t-1") - - if len(panel.PinnedSessions()) != 1 { - t.Fatalf("expected 1 pinned session, got %d", len(panel.PinnedSessions())) - } - if panel.PinnedSessions()[0] != "t-1" { - t.Errorf("expected t-1, got %s", panel.PinnedSessions()[0]) - } -} - -func TestSessionPanelPinIgnoresDuplicate(t *testing.T) { - panel := NewSessionPanelModel() - panel.Pin("t-1") - panel.Pin("t-1") - - if len(panel.PinnedSessions()) != 1 { - t.Errorf("expected 1 pinned session, got %d", len(panel.PinnedSessions())) - } -} - -func TestSessionPanelUnpin(t *testing.T) { - panel := NewSessionPanelModel() - panel.Pin("t-1") - panel.Pin("t-2") - panel.Unpin("t-1") - - if len(panel.PinnedSessions()) != 1 { - t.Fatalf("expected 1, got %d", len(panel.PinnedSessions())) - } - if panel.PinnedSessions()[0] != "t-2" { - t.Errorf("expected t-2, got %s", panel.PinnedSessions()[0]) - } -} - -func TestSessionPanelUnpinClampsFocus(t *testing.T) { - panel := NewSessionPanelModel() - panel.Pin("t-1") - panel.Pin("t-2") - panel.SetActivePaneIdx(1) - panel.Unpin("t-2") - - if panel.ActivePaneIdx() != 0 { - t.Errorf("expected clamped to 0, got %d", panel.ActivePaneIdx()) - } -} - -func TestSessionPanelCycleRight(t *testing.T) { - panel := NewSessionPanelModel() - panel.Pin("t-1") - panel.Pin("t-2") - panel.Pin("t-3") - - panel.CycleRight() - if panel.ActivePaneIdx() != 1 { - t.Errorf("expected 1, got %d", panel.ActivePaneIdx()) - } - - panel.CycleRight() - panel.CycleRight() - if panel.ActivePaneIdx() != 2 { - t.Errorf("expected clamped to 2, got %d", panel.ActivePaneIdx()) - } -} - -func TestSessionPanelCycleLeft(t *testing.T) { - panel := NewSessionPanelModel() - panel.Pin("t-1") - panel.Pin("t-2") - panel.SetActivePaneIdx(1) - - panel.CycleLeft() - if panel.ActivePaneIdx() != 0 { - t.Errorf("expected 0, got %d", panel.ActivePaneIdx()) - } - - panel.CycleLeft() - if panel.ActivePaneIdx() != 0 { - t.Errorf("expected clamped to 0, got %d", panel.ActivePaneIdx()) - } -} - -func TestSessionPanelActiveThreadID(t *testing.T) { - panel := NewSessionPanelModel() - if panel.ActiveThreadID() != "" { - t.Errorf("expected empty, got %s", panel.ActiveThreadID()) - } - - panel.Pin("t-1") - panel.Pin("t-2") - panel.SetActivePaneIdx(1) - - if panel.ActiveThreadID() != "t-2" { - t.Errorf("expected t-2, got %s", panel.ActiveThreadID()) - } -} - -func TestSessionPanelIsPinned(t *testing.T) { - panel := NewSessionPanelModel() - panel.Pin("t-1") - - if !panel.IsPinned("t-1") { - t.Error("expected t-1 to be pinned") - } - if panel.IsPinned("t-2") { - t.Error("expected t-2 to not be pinned") - } -} - -func TestSessionPanelSplitRatio(t *testing.T) { - panel := NewSessionPanelModel() - if panel.SplitRatio() != defaultSplitRatio { - t.Errorf("expected %f, got %f", defaultSplitRatio, panel.SplitRatio()) - } -} - -func TestSessionPanelSessionDimensions(t *testing.T) { - panel := NewSessionPanelModel() - panel.Pin("t-1") - panel.Pin("t-2") - - width, height := panel.SessionDimensions(120, 40) - expectedWidth := 120 / 2 - expectedHeight := 40 - dividerHeight - if width != expectedWidth { - t.Errorf("expected width %d, got %d", expectedWidth, width) - } - if height != expectedHeight { - t.Errorf("expected height %d, got %d", expectedHeight, height) - } -} - -func TestSessionPanelSessionDimensionsEmpty(t *testing.T) { - panel := NewSessionPanelModel() - width, height := panel.SessionDimensions(120, 40) - if width != 0 || height != 0 { - t.Errorf("expected 0,0 for empty panel, got %d,%d", width, height) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui -run TestSessionPanel -v` -Expected: FAIL — `NewSessionPanelModel` undefined - -**Step 3: Write minimal implementation** - -Create `internal/tui/session_panel.go`: - -```go -package tui - -const ( - defaultSplitRatio = 0.5 - dividerHeight = 1 -) - -type SessionPanelModel struct { - pinnedSessions []string - activePaneIdx int - splitRatio float64 -} - -func NewSessionPanelModel() SessionPanelModel { - return SessionPanelModel{ - splitRatio: defaultSplitRatio, - } -} - -func (panel *SessionPanelModel) Pin(threadID string) { - if panel.IsPinned(threadID) { - return - } - panel.pinnedSessions = append(panel.pinnedSessions, threadID) -} - -func (panel *SessionPanelModel) Unpin(threadID string) { - filtered := make([]string, 0, len(panel.pinnedSessions)) - for _, pinned := range panel.pinnedSessions { - if pinned != threadID { - filtered = append(filtered, pinned) - } - } - panel.pinnedSessions = filtered - panel.clampActivePaneIdx() -} - -func (panel *SessionPanelModel) IsPinned(threadID string) bool { - for _, pinned := range panel.pinnedSessions { - if pinned == threadID { - return true - } - } - return false -} - -func (panel *SessionPanelModel) PinnedSessions() []string { - return panel.pinnedSessions -} - -func (panel *SessionPanelModel) ActivePaneIdx() int { - return panel.activePaneIdx -} - -func (panel *SessionPanelModel) SetActivePaneIdx(index int) { - panel.activePaneIdx = index - panel.clampActivePaneIdx() -} - -func (panel *SessionPanelModel) ActiveThreadID() string { - if len(panel.pinnedSessions) == 0 { - return "" - } - return panel.pinnedSessions[panel.activePaneIdx] -} - -func (panel *SessionPanelModel) CycleRight() { - maxIdx := len(panel.pinnedSessions) - 1 - if panel.activePaneIdx < maxIdx { - panel.activePaneIdx++ - } -} - -func (panel *SessionPanelModel) CycleLeft() { - if panel.activePaneIdx > 0 { - panel.activePaneIdx-- - } -} - -func (panel SessionPanelModel) SplitRatio() float64 { - return panel.splitRatio -} - -func (panel SessionPanelModel) SessionDimensions(panelWidth int, panelHeight int) (int, int) { - count := len(panel.pinnedSessions) - if count == 0 { - return 0, 0 - } - sessionWidth := panelWidth / count - sessionHeight := panelHeight - dividerHeight - return sessionWidth, sessionHeight -} - -func (panel *SessionPanelModel) clampActivePaneIdx() { - maxIdx := len(panel.pinnedSessions) - 1 - if maxIdx < 0 { - panel.activePaneIdx = 0 - return - } - if panel.activePaneIdx > maxIdx { - panel.activePaneIdx = maxIdx - } -} -``` - -**Step 4: Run test to verify it passes** - -Run: `go test ./internal/tui -run TestSessionPanel -v` -Expected: All SessionPanel tests pass - -**Step 5: Run full test suite** - -Run: `go test ./... -v -race` -Expected: All tests pass (no regressions) - -**Step 6: Commit** - -```bash -git add internal/tui/session_panel.go internal/tui/session_panel_test.go -git commit -m "feat(tui): add SessionPanelModel for pinned session management" -``` - ---- - -## Task 3: Integrate SessionPanelModel into AppModel - -Replace the single `session *SessionModel` field with `sessionPanel SessionPanelModel` and update the `focusPane` field. Migrate existing single-session behavior to work through the panel. Preserve all existing tests. - -**Files:** -- Modify: `internal/tui/app.go` -- Modify: `internal/tui/app_pty.go` -- Modify: `internal/tui/app_view.go` -- Modify: `internal/tui/app_test.go` - -**Step 1: Write the failing test** - -Add to `internal/tui/app_test.go`: - -```go -func TestAppHasPinnedSessions(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - if len(app.sessionPanel.PinnedSessions()) != 0 { - t.Errorf("expected 0 pinned sessions, got %d", len(app.sessionPanel.PinnedSessions())) - } -} - -func TestAppFocusPaneDefaultsToCanvas(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - if app.focusPane != FocusPaneCanvas { - t.Errorf("expected FocusPaneCanvas, got %d", app.focusPane) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui -run "TestAppHasPinnedSessions|TestAppFocusPaneDefaultsToCanvas" -v` -Expected: FAIL — `app.sessionPanel` and `app.focusPane` undefined - -**Step 3: Refactor AppModel struct** - -In `internal/tui/app.go`, replace these fields: - -```go -// REMOVE these fields from AppModel: -// session *SessionModel -// focus int - -// ADD these fields to AppModel: -sessionPanel SessionPanelModel -focusPane FocusPane -``` - -The full updated `AppModel` struct: - -```go -type AppModel struct { - store *state.ThreadStore - client *appserver.Client - statusBar *StatusBar - canvas CanvasModel - tree TreeModel - prefix *PrefixHandler - menu MenuModel - help HelpModel - menuVisible bool - helpVisible bool - focusPane FocusPane - canvasMode int - width int - height int - sessionID string - currentMessageID string - events chan appserver.ProtoEvent - ptySessions map[string]*PTYSession - ptyEvents chan PTYOutputMsg - interactiveCmd string - interactiveArgs []string - sessionPanel SessionPanelModel -} -``` - -Keep the focus constants `FocusCanvas`, `FocusTree` as `canvasMode` values (rename the field for clarity): - -```go -const ( - CanvasModeGrid = iota - CanvasModeTree -) -``` - -Update `NewAppModel`: - -```go -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(), - } - for _, opt := range opts { - opt(&app) - } - return app -} -``` - -**Step 4: Update Focus() accessor** - -Replace `Focus()` with `FocusPane()`: - -```go -func (app AppModel) FocusPane() FocusPane { - return app.focusPane -} - -func (app AppModel) CanvasMode() int { - return app.canvasMode -} -``` - -**Step 5: Update handleKey routing** - -In `internal/tui/app.go`, update `handleKey`: - -```go -func (app AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if app.helpVisible { - return app.handleHelpKey(msg) - } - - if app.menuVisible { - return app.handleMenuKey(msg) - } - - prefixResult := app.prefix.HandleKey(msg) - switch prefixResult { - case PrefixWaiting: - return app, nil - case PrefixComplete: - return app.handlePrefixAction() - case PrefixCancelled: - return app, nil - } - - if app.focusPane == FocusPaneSession { - return app.handleSessionKey(msg) - } - - switch msg.Type { - case tea.KeyCtrlC, tea.KeyEsc: - return app, tea.Quit - case tea.KeyEnter: - return app.openSession() - case tea.KeyRunes: - return app.handleRune(msg) - default: - return app.handleArrow(msg) - } -} -``` - -**Step 6: Update toggleFocus to use canvasMode** - -```go -func (app *AppModel) toggleCanvasMode() { - if app.canvasMode == CanvasModeGrid { - app.canvasMode = CanvasModeTree - return - } - app.canvasMode = CanvasModeGrid -} -``` - -Update `handleRune` to call `toggleCanvasMode()` instead of `toggleFocus()`. - -Update `handleArrow` to check `app.canvasMode == CanvasModeTree` instead of `app.focus == FocusTree`. - -**Step 7: Update app_pty.go** - -In `internal/tui/app_pty.go`: - -- `openSession()`: Instead of setting `app.session` and `app.focus`, pin the session and set focus: - -```go -func (app AppModel) openSession() (tea.Model, tea.Cmd) { - threadID := app.canvas.SelectedThreadID() - if threadID == "" { - return app, nil - } - - thread, exists := app.store.Get(threadID) - if !exists { - return app, nil - } - - if !app.sessionPanel.IsPinned(threadID) { - existingPTY, hasExisting := app.ptySessions[threadID] - if !hasExisting { - 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 - existingPTY = ptySession - } - _ = thread - app.sessionPanel.Pin(threadID) - } - - app.focusPane = FocusPaneSession - app.sessionPanel.SetActivePaneIdx(app.pinnedIndex(threadID)) - return app, app.rebalancePTYSizes() -} -``` - -Add helper: - -```go -func (app AppModel) pinnedIndex(threadID string) int { - for index, pinned := range app.sessionPanel.PinnedSessions() { - if pinned == threadID { - return index - } - } - return 0 -} -``` - -- `closeSession()`: Return focus to canvas without unpinning: - -```go -func (app *AppModel) closeSession() { - app.focusPane = FocusPaneCanvas -} -``` - -- `handleSessionKey()`: Forward keys to the active pinned PTY: - -```go -func (app AppModel) handleSessionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyCtrlC: - return app, tea.Quit - case tea.KeyEsc: - app.closeSession() - return app, nil - default: - return app.forwardKeyToPTY(msg) - } -} - -func (app AppModel) forwardKeyToPTY(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - activeID := app.sessionPanel.ActiveThreadID() - if activeID == "" { - return app, nil - } - - ptySession, exists := app.ptySessions[activeID] - if !exists { - return app, nil - } - - data := KeyMsgToBytes(msg) - if data == nil { - return app, nil - } - - ptySession.WriteBytes(data) - return app, nil -} -``` - -- `handlePTYOutput()`: Check if the exited session is pinned: - -```go -func (app AppModel) handlePTYOutput(msg PTYOutputMsg) (tea.Model, tea.Cmd) { - return app, app.listenForPTYEvents() -} -``` - -- Add `rebalancePTYSizes()`: - -```go -func (app AppModel) rebalancePTYSizes() tea.Cmd { - pinned := app.sessionPanel.PinnedSessions() - if len(pinned) == 0 { - return nil - } - - canvasHeight := int(float64(app.height) * app.sessionPanel.SplitRatio()) - panelHeight := app.height - canvasHeight - dividerHeight - sessionWidth, _ := app.sessionPanel.SessionDimensions(app.width, panelHeight) - - for _, threadID := range pinned { - ptySession, exists := app.ptySessions[threadID] - if exists { - ptySession.Resize(sessionWidth, panelHeight) - } - } - return nil -} -``` - -**Step 8: Update app_view.go** - -In `internal/tui/app_view.go`, update `View()`: - -```go -func (app AppModel) View() string { - title := titleStyle.Render("DJ — Codex TUI Visualizer") - status := app.statusBar.View() - - if app.helpVisible { - return title + "\n" + app.help.View() + "\n" + status - } - - if app.menuVisible { - 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 - } - - divider := app.renderDivider() - panel := app.renderSessionPanel() - return title + "\n" + canvas + "\n" + divider + "\n" + panel + "\n" + status -} - -func (app AppModel) renderCanvas() string { - canvas := app.canvas.View() - if app.canvasMode == CanvasModeTree { - treeView := app.tree.View() - return lipgloss.JoinHorizontal(lipgloss.Top, treeView+" ", canvas) - } - return canvas -} - -func (app AppModel) renderDivider() string { - return lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). - Width(app.width). - Render(strings.Repeat("─", app.width)) -} - -func (app AppModel) renderSessionPanel() string { - pinned := app.sessionPanel.PinnedSessions() - count := len(pinned) - if count == 0 { - return "" - } - - canvasHeight := int(float64(app.height) * app.sessionPanel.SplitRatio()) - panelHeight := app.height - canvasHeight - dividerHeight - sessionWidth := app.width / count - - panes := make([]string, count) - for index, threadID := range pinned { - content := "" - ptySession, exists := app.ptySessions[threadID] - if exists { - content = ptySession.Render() - } - - isActive := index == app.sessionPanel.ActivePaneIdx() && app.focusPane == FocusPaneSession - style := app.sessionPaneStyle(sessionWidth, panelHeight, isActive) - panes[index] = style.Render(content) - } - return lipgloss.JoinHorizontal(lipgloss.Top, panes...) -} - -func (app AppModel) sessionPaneStyle(width int, height int, active bool) lipgloss.Style { - borderColor := lipgloss.Color("240") - if active { - borderColor = lipgloss.Color("39") - } - return lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(borderColor). - Width(width - 2). - Height(height - 2) -} -``` - -**Step 9: Update WindowSizeMsg handler** - -In `app.go` `Update()`, update the `WindowSizeMsg` case: - -```go -case tea.WindowSizeMsg: - app.width = msg.Width - app.height = msg.Height - app.statusBar.SetWidth(msg.Width) - return app, app.rebalancePTYSizes() -``` - -**Step 10: Update existing tests** - -All existing tests that reference `app.Focus()` must be updated to use `app.FocusPane()` or `app.CanvasMode()`. Tests that check `FocusSession` must check `FocusPaneSession`. Tests that check `FocusCanvas` must check `FocusPaneCanvas`. Tests that check `FocusTree` must check `app.CanvasMode() == CanvasModeTree`. - -Key test updates in `app_test.go`: -- `TestAppToggleFocus`: Change `app.Focus() == FocusTree` → `appModel.CanvasMode() == CanvasModeTree` -- `TestAppTreeNavigationWhenFocused`: Change the focus setup and assertions -- `TestAppEnterOpensSession`: Check `app.FocusPane() == FocusPaneSession` and `len(app.sessionPanel.PinnedSessions()) == 1` -- `TestAppEscClosesSession`: Check `app.FocusPane() == FocusPaneCanvas` (pinned sessions remain) -- `TestAppReconnectsExistingPTY`: Check pinned session count stays 1 -- `TestAppForwardKeyToPTY`: Update to work through pinned panel - -**Step 11: Run all tests** - -Run: `go test ./... -v -race` -Expected: All tests pass - -**Step 12: Run linter** - -Run: `golangci-lint run` -Expected: No issues (all files under 300 lines, functions under 60 lines) - -**Step 13: Commit** - -```bash -git add internal/tui/app.go internal/tui/app_view.go internal/tui/app_pty.go internal/tui/app_test.go -git commit -m "refactor(tui): integrate SessionPanelModel, replace single-session with pinned panel" -``` - ---- - -## Task 4: Add Space key to toggle pin from canvas - -When the user presses Space on a canvas card, it pins or unpins that thread's session in the bottom panel. - -**Files:** -- Modify: `internal/tui/app.go` (handleRune) -- Modify: `internal/tui/app_pty.go` (togglePin) -- Modify: `internal/tui/app_test.go` - -**Step 1: Write the failing test** - -Add to `internal/tui/app_test.go`: - -```go -func TestAppSpacePinsSession(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Thread 1") - app := NewAppModel(store, WithInteractiveCommand("echo", "hello")) - - spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(spaceKey) - appModel := updated.(AppModel) - defer appModel.StopAllPTYSessions() - - if len(appModel.sessionPanel.PinnedSessions()) != 1 { - t.Fatalf("expected 1 pinned, got %d", len(appModel.sessionPanel.PinnedSessions())) - } - if appModel.sessionPanel.PinnedSessions()[0] != "t-1" { - t.Errorf("expected t-1, got %s", appModel.sessionPanel.PinnedSessions()[0]) - } -} - -func TestAppSpaceUnpinsSession(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Thread 1") - app := NewAppModel(store, WithInteractiveCommand("echo", "hello")) - - spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(spaceKey) - appModel := updated.(AppModel) - - updated2, _ := appModel.Update(spaceKey) - appModel2 := updated2.(AppModel) - defer appModel2.StopAllPTYSessions() - - if len(appModel2.sessionPanel.PinnedSessions()) != 0 { - t.Errorf("expected 0 pinned after unpin, got %d", len(appModel2.sessionPanel.PinnedSessions())) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui -run "TestAppSpacePinsSession|TestAppSpaceUnpinsSession" -v` -Expected: FAIL — Space key does nothing - -**Step 3: Implement toggle pin** - -In `internal/tui/app.go` `handleRune`, add the space case: - -```go -func (app AppModel) handleRune(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "t": - app.toggleCanvasMode() - case "n": - return app, app.createThread() - case "?": - app.helpVisible = !app.helpVisible - case " ": - return app.togglePin() - } - return app, nil -} -``` - -In `internal/tui/app_pty.go`, add `togglePin`: - -```go -func (app AppModel) togglePin() (tea.Model, tea.Cmd) { - threadID := app.canvas.SelectedThreadID() - if threadID == "" { - return app, nil - } - - if app.sessionPanel.IsPinned(threadID) { - app.sessionPanel.Unpin(threadID) - return app, app.rebalancePTYSizes() - } - - return app.pinSession(threadID) -} - -func (app AppModel) pinSession(threadID string) (tea.Model, tea.Cmd) { - _, exists := app.store.Get(threadID) - if !exists { - 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.sessionPanel.Pin(threadID) - return app, app.rebalancePTYSizes() -} -``` - -Refactor `openSession` to reuse `pinSession`: - -```go -func (app AppModel) openSession() (tea.Model, tea.Cmd) { - threadID := app.canvas.SelectedThreadID() - if threadID == "" { - return app, nil - } - - if !app.sessionPanel.IsPinned(threadID) { - pinned, cmd := app.pinSession(threadID) - app = pinned.(AppModel) - if cmd != nil { - _ = cmd - } - } - - app.focusPane = FocusPaneSession - app.sessionPanel.SetActivePaneIdx(app.pinnedIndex(threadID)) - return app, app.rebalancePTYSizes() -} -``` - -**Step 4: Run test to verify it passes** - -Run: `go test ./internal/tui -run "TestAppSpacePinsSession|TestAppSpaceUnpinsSession" -v` -Expected: PASS - -**Step 5: Run full test suite** - -Run: `go test ./... -v -race` -Expected: All tests pass - -**Step 6: Commit** - -```bash -git add internal/tui/app.go internal/tui/app_pty.go internal/tui/app_test.go -git commit -m "feat(tui): space key toggles pin/unpin session from canvas" -``` - ---- - -## Task 5: Add Tab key to switch between canvas and session panel - -Tab moves focus down to the session panel (if sessions are pinned). Esc from session panel returns focus to canvas. - -**Files:** -- Modify: `internal/tui/app.go` -- Modify: `internal/tui/app_test.go` - -**Step 1: Write the failing test** - -Add to `internal/tui/app_test.go`: - -```go -func TestAppTabSwitchesToSessionPanel(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Thread 1") - app := NewAppModel(store, WithInteractiveCommand("echo", "hello")) - - spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(spaceKey) - app = updated.(AppModel) - - tabKey := tea.KeyMsg{Type: tea.KeyTab} - updated, _ = app.Update(tabKey) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - if app.FocusPane() != FocusPaneSession { - t.Errorf("expected FocusPaneSession, got %d", app.FocusPane()) - } -} - -func TestAppTabDoesNothingWithNoPinnedSessions(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - tabKey := tea.KeyMsg{Type: tea.KeyTab} - updated, _ := app.Update(tabKey) - app = updated.(AppModel) - - if app.FocusPane() != FocusPaneCanvas { - t.Errorf("expected FocusPaneCanvas, got %d", app.FocusPane()) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui -run "TestAppTabSwitches|TestAppTabDoesNothing" -v` -Expected: FAIL — Tab currently moves canvas selection - -**Step 3: Implement Tab focus switch** - -In `internal/tui/app.go`, update `handleKey` to intercept Tab before arrow handling: - -```go -func (app AppModel) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if app.helpVisible { - return app.handleHelpKey(msg) - } - - if app.menuVisible { - return app.handleMenuKey(msg) - } - - prefixResult := app.prefix.HandleKey(msg) - switch prefixResult { - case PrefixWaiting: - return app, nil - case PrefixComplete: - return app.handlePrefixAction() - case PrefixCancelled: - return app, nil - } - - if app.focusPane == FocusPaneSession { - return app.handleSessionKey(msg) - } - - switch msg.Type { - case tea.KeyCtrlC, tea.KeyEsc: - return app, tea.Quit - case tea.KeyEnter: - return app.openSession() - case tea.KeyTab: - return app.switchToSessionPanel() - case tea.KeyRunes: - return app.handleRune(msg) - default: - return app.handleArrow(msg) - } -} - -func (app AppModel) switchToSessionPanel() (tea.Model, tea.Cmd) { - hasPinned := len(app.sessionPanel.PinnedSessions()) > 0 - if !hasPinned { - return app, nil - } - app.focusPane = FocusPaneSession - return app, nil -} -``` - -Also remove `tea.KeyTab` from `handleCanvasArrow` (it was previously mapped to `MoveRight`): - -```go -func (app *AppModel) handleCanvasArrow(msg tea.KeyMsg) { - switch msg.Type { - case tea.KeyRight: - app.canvas.MoveRight() - case tea.KeyLeft, tea.KeyShiftTab: - app.canvas.MoveLeft() - case tea.KeyDown: - app.canvas.MoveDown() - case tea.KeyUp: - app.canvas.MoveUp() - } -} -``` - -**Step 4: Run test to verify it passes** - -Run: `go test ./internal/tui -run "TestAppTabSwitches|TestAppTabDoesNothing" -v` -Expected: PASS - -**Step 5: Run full test suite** - -Run: `go test ./... -v -race` -Expected: All tests pass - -**Step 6: Commit** - -```bash -git add internal/tui/app.go internal/tui/app_test.go -git commit -m "feat(tui): Tab key switches focus between canvas and session panel" -``` - ---- - -## Task 6: Add Ctrl+B prefix actions for session panel - -Add panel navigation bindings: `Ctrl+B ←/→` cycles panes, `Ctrl+B x` unpins focused session, `Ctrl+B z` toggles zoom (full-height single session). - -**Files:** -- Modify: `internal/tui/app_menu.go` (handlePrefixAction) -- Modify: `internal/tui/session_panel.go` (zoom state) -- Modify: `internal/tui/session_panel_test.go` -- Modify: `internal/tui/app_test.go` - -**Step 1: Write the failing test for zoom toggle** - -Add to `internal/tui/session_panel_test.go`: - -```go -func TestSessionPanelZoomToggle(t *testing.T) { - panel := NewSessionPanelModel() - panel.Pin("t-1") - panel.Pin("t-2") - - if panel.Zoomed() { - t.Error("expected not zoomed initially") - } - - panel.ToggleZoom() - if !panel.Zoomed() { - t.Error("expected zoomed after toggle") - } - - panel.ToggleZoom() - if panel.Zoomed() { - t.Error("expected not zoomed after second toggle") - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui -run TestSessionPanelZoomToggle -v` -Expected: FAIL — `Zoomed()` and `ToggleZoom()` undefined - -**Step 3: Add zoom to SessionPanelModel** - -In `internal/tui/session_panel.go`, add `zoomed bool` field and methods: - -```go -type SessionPanelModel struct { - pinnedSessions []string - activePaneIdx int - splitRatio float64 - zoomed bool -} - -func (panel *SessionPanelModel) Zoomed() bool { - return panel.zoomed -} - -func (panel *SessionPanelModel) ToggleZoom() { - panel.zoomed = !panel.zoomed -} -``` - -**Step 4: Run test to verify it passes** - -Run: `go test ./internal/tui -run TestSessionPanelZoomToggle -v` -Expected: PASS - -**Step 5: Write failing tests for prefix actions** - -Add to `internal/tui/app_test.go`: - -```go -func TestAppCtrlBXUnpinsSession(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Thread 1") - app := NewAppModel(store, WithInteractiveCommand("echo", "hello")) - - spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(spaceKey) - app = updated.(AppModel) - - tabKey := tea.KeyMsg{Type: tea.KeyTab} - updated, _ = app.Update(tabKey) - app = updated.(AppModel) - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - updated, _ = app.Update(ctrlB) - app = updated.(AppModel) - - xKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}} - updated, _ = app.Update(xKey) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - if len(app.sessionPanel.PinnedSessions()) != 0 { - t.Errorf("expected 0 pinned after unpin, got %d", len(app.sessionPanel.PinnedSessions())) - } - if app.FocusPane() != FocusPaneCanvas { - t.Errorf("expected focus back to canvas, got %d", app.FocusPane()) - } -} - -func TestAppCtrlBZTogglesZoom(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Thread 1") - app := NewAppModel(store, WithInteractiveCommand("echo", "hello")) - - spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(spaceKey) - app = updated.(AppModel) - - tabKey := tea.KeyMsg{Type: tea.KeyTab} - updated, _ = app.Update(tabKey) - app = updated.(AppModel) - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - updated, _ = app.Update(ctrlB) - app = updated.(AppModel) - - zKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}} - updated, _ = app.Update(zKey) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - if !app.sessionPanel.Zoomed() { - t.Error("expected zoomed after Ctrl+B z") - } -} -``` - -**Step 6: Run test to verify they fail** - -Run: `go test ./internal/tui -run "TestAppCtrlBXUnpins|TestAppCtrlBZToggles" -v` -Expected: FAIL — prefix actions 'x' and 'z' not handled - -**Step 7: Implement prefix actions** - -In `internal/tui/app_menu.go`, expand `handlePrefixAction`: - -```go -func (app AppModel) handlePrefixAction() (tea.Model, tea.Cmd) { - action := app.prefix.Action() - switch action { - case 'm': - app.showMenu() - case 'x': - return app.unpinActiveSession() - case 'z': - return app.toggleZoom() - } - return app, nil -} - -func (app AppModel) unpinActiveSession() (tea.Model, tea.Cmd) { - activeID := app.sessionPanel.ActiveThreadID() - if activeID == "" { - return app, nil - } - app.sessionPanel.Unpin(activeID) - hasPinned := len(app.sessionPanel.PinnedSessions()) > 0 - if !hasPinned { - app.focusPane = FocusPaneCanvas - } - return app, app.rebalancePTYSizes() -} - -func (app AppModel) toggleZoom() (tea.Model, tea.Cmd) { - app.sessionPanel.ToggleZoom() - return app, app.rebalancePTYSizes() -} -``` - -**Step 8: Run tests to verify they pass** - -Run: `go test ./internal/tui -run "TestAppCtrlBXUnpins|TestAppCtrlBZToggles" -v` -Expected: PASS - -**Step 9: Run full test suite** - -Run: `go test ./... -v -race` -Expected: All tests pass - -**Step 10: Commit** - -```bash -git add internal/tui/app_menu.go internal/tui/session_panel.go internal/tui/session_panel_test.go internal/tui/app_test.go -git commit -m "feat(tui): Ctrl+B x/z prefix actions for unpin and zoom" -``` - ---- - -## Task 7: Update session panel View() for zoom mode - -When zoomed, only the active session renders at full panel width. When not zoomed, all pinned sessions render side-by-side. - -**Files:** -- Modify: `internal/tui/app_view.go` -- Modify: `internal/tui/app_test.go` - -**Step 1: Write the failing test** - -Add to `internal/tui/app_test.go`: - -```go -func TestAppViewShowsDividerWhenPinned(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Thread 1") - app := NewAppModel(store, WithInteractiveCommand("echo", "hello")) - app.width = 120 - app.height = 40 - - spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(spaceKey) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - view := app.View() - if !strings.Contains(view, "─") { - t.Error("expected divider line in view when sessions pinned") - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui -run TestAppViewShowsDividerWhenPinned -v` -Expected: FAIL — no divider character present - -**Step 3: Update renderSessionPanel for zoom** - -In `internal/tui/app_view.go`, update `renderSessionPanel`: - -```go -func (app AppModel) renderSessionPanel() string { - pinned := app.sessionPanel.PinnedSessions() - count := len(pinned) - if count == 0 { - return "" - } - - canvasHeight := int(float64(app.height) * app.sessionPanel.SplitRatio()) - panelHeight := app.height - canvasHeight - dividerHeight - - if app.sessionPanel.Zoomed() { - return app.renderZoomedSession(panelHeight) - } - return app.renderSideBySideSessions(pinned, panelHeight) -} - -func (app AppModel) renderZoomedSession(panelHeight int) string { - activeID := app.sessionPanel.ActiveThreadID() - if activeID == "" { - return "" - } - - content := "" - ptySession, exists := app.ptySessions[activeID] - if exists { - content = ptySession.Render() - } - - style := app.sessionPaneStyle(app.width, panelHeight, true) - return style.Render(content) -} - -func (app AppModel) renderSideBySideSessions(pinned []string, panelHeight int) string { - count := len(pinned) - sessionWidth := app.width / count - - panes := make([]string, count) - for index, threadID := range pinned { - content := "" - ptySession, exists := app.ptySessions[threadID] - if exists { - content = ptySession.Render() - } - - isActive := index == app.sessionPanel.ActivePaneIdx() && app.focusPane == FocusPaneSession - style := app.sessionPaneStyle(sessionWidth, panelHeight, isActive) - panes[index] = style.Render(content) - } - return lipgloss.JoinHorizontal(lipgloss.Top, panes...) -} -``` - -**Step 4: Run test to verify it passes** - -Run: `go test ./internal/tui -run TestAppViewShowsDividerWhenPinned -v` -Expected: PASS - -**Step 5: Run full test suite + linter** - -Run: `go test ./... -v -race && golangci-lint run` -Expected: All pass, no lint issues - -**Step 6: Commit** - -```bash -git add internal/tui/app_view.go internal/tui/app_test.go -git commit -m "feat(tui): zoom mode renders single session at full panel width" -``` - ---- - -## Task 8: Update help overlay with new keybindings - -Add the new pinning and panel keybindings to the help screen. - -**Files:** -- Modify: `internal/tui/help.go` -- Modify: `internal/tui/app_test.go` (optional: verify help content) - -**Step 1: Write the failing test** - -Add to `internal/tui/app_test.go`: - -```go -func TestHelpShowsPinKeybinding(t *testing.T) { - help := NewHelpModel() - view := help.View() - if !strings.Contains(view, "Space") { - t.Error("expected Space keybinding in help") - } - if !strings.Contains(view, "Ctrl+B x") { - t.Error("expected Ctrl+B x keybinding in help") - } - if !strings.Contains(view, "Ctrl+B z") { - t.Error("expected Ctrl+B z keybinding in help") - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui -run TestHelpShowsPinKeybinding -v` -Expected: FAIL — Space/Ctrl+B x/Ctrl+B z not in help - -**Step 3: Update keybindings list** - -In `internal/tui/help.go`, update the keybindings slice: - -```go -var keybindings = []keybinding{ - {"←/→", "Navigate cards horizontally"}, - {"↑/↓", "Navigate cards vertically"}, - {"Enter", "Open + focus session"}, - {"Space", "Toggle pin/unpin session"}, - {"Tab", "Switch to session panel"}, - {"Esc", "Back / close overlay"}, - {"t", "Toggle tree view"}, - {"n", "New thread"}, - {"Ctrl+B", "Prefix key (tmux-style)"}, - {"Ctrl+B m", "Open context menu"}, - {"Ctrl+B x", "Unpin focused session"}, - {"Ctrl+B z", "Toggle zoom session"}, - {"?", "Toggle help"}, - {"Ctrl+C", "Quit"}, -} -``` - -**Step 4: Run test to verify it passes** - -Run: `go test ./internal/tui -run TestHelpShowsPinKeybinding -v` -Expected: PASS - -**Step 5: Run full test suite** - -Run: `go test ./... -v -race` -Expected: All tests pass - -**Step 6: Commit** - -```bash -git add internal/tui/help.go internal/tui/app_test.go -git commit -m "docs(tui): update help overlay with pin/unpin/zoom keybindings" -``` - ---- - -## Task 9: Remove orphaned SessionModel - -The old `SessionModel` in `session.go` is no longer used — the session panel renders directly from `PTYSession`. Remove it and its tests. - -**Files:** -- Delete: `internal/tui/session.go` -- Delete: `internal/tui/session_test.go` -- Modify: `internal/tui/app_view.go` (remove any remaining references) - -**Step 1: Search for SessionModel references** - -Run: `grep -r "SessionModel" internal/tui/` -Verify that no remaining code references `SessionModel`, `NewSessionModel`, or `session.View()`. - -**Step 2: Delete the files** - -```bash -rm internal/tui/session.go internal/tui/session_test.go -``` - -**Step 3: Run full test suite** - -Run: `go test ./... -v -race` -Expected: All tests pass (no references to deleted code) - -**Step 4: Run linter** - -Run: `golangci-lint run` -Expected: No issues - -**Step 5: Commit** - -```bash -git add -u internal/tui/session.go internal/tui/session_test.go -git commit -m "refactor(tui): remove orphaned SessionModel replaced by session panel" -``` - ---- - -## Task 10: Divider bar with tab-style session labels - -Replace the plain `───` divider with labeled tabs showing which sessions are pinned and which is active. - -**Files:** -- Create: `internal/tui/divider.go` -- Create: `internal/tui/divider_test.go` -- Modify: `internal/tui/app_view.go` (use new divider) - -**Step 1: Write the failing test** - -Create `internal/tui/divider_test.go`: - -```go -package tui - -import ( - "strings" - "testing" -) - -func TestDividerRenderShowsLabels(t *testing.T) { - sessions := []string{"agent-a", "agent-b"} - result := renderDividerBar(sessions, 1, 80) - - if !strings.Contains(result, "agent-a") { - t.Error("expected agent-a in divider") - } - if !strings.Contains(result, "agent-b") { - t.Error("expected agent-b in divider") - } -} - -func TestDividerRenderHighlightsActive(t *testing.T) { - sessions := []string{"agent-a", "agent-b"} - result := renderDividerBar(sessions, 0, 80) - - if !strings.Contains(result, "agent-a") { - t.Error("expected agent-a label present") - } -} - -func TestDividerRenderEmpty(t *testing.T) { - result := renderDividerBar(nil, 0, 80) - if result != "" { - t.Errorf("expected empty string for no sessions, got %q", result) - } -} - -func TestDividerRenderNumbersLabels(t *testing.T) { - sessions := []string{"a", "b", "c"} - result := renderDividerBar(sessions, 0, 120) - - if !strings.Contains(result, "1:") { - t.Error("expected numbered label starting at 1") - } - if !strings.Contains(result, "3:") { - t.Error("expected label 3 for third session") - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui -run TestDividerRender -v` -Expected: FAIL — `renderDividerBar` undefined - -**Step 3: Implement divider** - -Create `internal/tui/divider.go`: - -```go -package tui - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" -) - -var ( - dividerLineStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) - dividerActiveTabStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("39")). - Bold(true) - dividerInactiveTabStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")) -) - -func renderDividerBar(sessions []string, activeIdx int, width int) string { - if len(sessions) == 0 { - return "" - } - - var tabs []string - for index, name := range sessions { - label := fmt.Sprintf(" %d: %s ", index+1, truncateLabel(name, 20)) - if index == activeIdx { - tabs = append(tabs, dividerActiveTabStyle.Render(label)) - } else { - tabs = append(tabs, dividerInactiveTabStyle.Render(label)) - } - } - - tabBar := strings.Join(tabs, dividerLineStyle.Render("│")) - remaining := width - lipgloss.Width(tabBar) - if remaining > 0 { - tabBar += dividerLineStyle.Render(strings.Repeat("─", remaining)) - } - return tabBar -} - -func truncateLabel(text string, maxLen int) string { - if len(text) <= maxLen { - return text - } - return text[:maxLen-3] + "..." -} -``` - -**Step 4: Run test to verify it passes** - -Run: `go test ./internal/tui -run TestDividerRender -v` -Expected: PASS - -**Step 5: Wire into app_view.go** - -In `internal/tui/app_view.go`, replace `renderDivider()`: - -```go -func (app AppModel) renderDivider() string { - pinned := app.sessionPanel.PinnedSessions() - activeIdx := app.sessionPanel.ActivePaneIdx() - - labels := make([]string, len(pinned)) - for index, threadID := range pinned { - thread, exists := app.store.Get(threadID) - if exists { - labels[index] = thread.Title - } else { - labels[index] = threadID - } - } - return renderDividerBar(labels, activeIdx, app.width) -} -``` - -**Step 6: Run full test suite + linter** - -Run: `go test ./... -v -race && golangci-lint run` -Expected: All pass - -**Step 7: Commit** - -```bash -git add internal/tui/divider.go internal/tui/divider_test.go internal/tui/app_view.go -git commit -m "feat(tui): labeled divider bar with numbered session tabs" -``` - ---- - -## Task 11: Ctrl+B arrow keys cycle panes in session panel - -When focused on the session panel, `Ctrl+B ←` and `Ctrl+B →` cycle between pinned sessions. `Ctrl+B 1-9` jumps directly to that session index. - -**Files:** -- Modify: `internal/tui/prefix.go` (support arrow keys as prefix actions) -- Modify: `internal/tui/app_menu.go` (handle new prefix actions) -- Modify: `internal/tui/app_test.go` - -**Step 1: Write the failing test** - -Add to `internal/tui/app_test.go`: - -```go -func TestAppCtrlBRightCyclesPaneRight(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "Thread 1") - store.Add("t-2", "Thread 2") - app := NewAppModel(store, WithInteractiveCommand("echo", "hello")) - - space := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(space) - app = updated.(AppModel) - - app.canvas.MoveRight() - updated, _ = app.Update(space) - app = updated.(AppModel) - - tab := tea.KeyMsg{Type: tea.KeyTab} - updated, _ = app.Update(tab) - app = updated.(AppModel) - - if app.sessionPanel.ActivePaneIdx() != 0 { - t.Fatalf("expected active pane 0, got %d", app.sessionPanel.ActivePaneIdx()) - } - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - updated, _ = app.Update(ctrlB) - app = updated.(AppModel) - - rightKey := tea.KeyMsg{Type: tea.KeyRight} - updated, _ = app.Update(rightKey) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - if app.sessionPanel.ActivePaneIdx() != 1 { - t.Errorf("expected active pane 1, got %d", app.sessionPanel.ActivePaneIdx()) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui -run TestAppCtrlBRightCyclesPaneRight -v` -Expected: FAIL — arrow key not handled as prefix action - -**Step 3: Update prefix handler to support arrow keys** - -In `internal/tui/prefix.go`, add support for non-rune prefix completions. Add a `keyType` field alongside `action`: - -```go -type PrefixHandler struct { - active bool - action rune - keyType tea.KeyType -} - -func (handler *PrefixHandler) KeyType() tea.KeyType { - return handler.keyType -} - -func (handler *PrefixHandler) HandleKey(msg tea.KeyMsg) int { - if !handler.active { - if msg.Type == tea.KeyCtrlB { - handler.active = true - return PrefixWaiting - } - return PrefixNone - } - - handler.active = false - - if msg.Type == tea.KeyEsc { - return PrefixCancelled - } - - hasRunes := msg.Type == tea.KeyRunes && len(msg.Runes) > 0 - if hasRunes { - handler.action = msg.Runes[0] - handler.keyType = msg.Type - return PrefixComplete - } - - isArrow := msg.Type == tea.KeyLeft || msg.Type == tea.KeyRight || msg.Type == tea.KeyUp || msg.Type == tea.KeyDown - if isArrow { - handler.action = 0 - handler.keyType = msg.Type - return PrefixComplete - } - - return PrefixCancelled -} -``` - -**Step 4: Update handlePrefixAction for arrows and number keys** - -In `internal/tui/app_menu.go`: - -```go -func (app AppModel) handlePrefixAction() (tea.Model, tea.Cmd) { - action := app.prefix.Action() - keyType := app.prefix.KeyType() - - switch { - case action == 'm': - app.showMenu() - case action == 'x': - return app.unpinActiveSession() - case action == 'z': - return app.toggleZoom() - case keyType == tea.KeyRight: - app.sessionPanel.CycleRight() - case keyType == tea.KeyLeft: - app.sessionPanel.CycleLeft() - case action >= '1' && action <= '9': - return app.jumpToPane(action) - } - return app, nil -} - -func (app AppModel) jumpToPane(digit rune) (tea.Model, tea.Cmd) { - index := int(digit - '1') - pinned := app.sessionPanel.PinnedSessions() - if index >= len(pinned) { - return app, nil - } - app.sessionPanel.SetActivePaneIdx(index) - app.focusPane = FocusPaneSession - return app, nil -} -``` - -**Step 5: Run test to verify it passes** - -Run: `go test ./internal/tui -run TestAppCtrlBRightCyclesPaneRight -v` -Expected: PASS - -**Step 6: Run full test suite** - -Run: `go test ./... -v -race` -Expected: All tests pass - -**Step 7: Commit** - -```bash -git add internal/tui/prefix.go internal/tui/app_menu.go internal/tui/app_test.go -git commit -m "feat(tui): Ctrl+B arrow keys and 1-9 for session panel navigation" -``` - ---- - -## Task 12: Rebalance PTY sizes on all layout changes - -Ensure PTY resize is called whenever: sessions are pinned/unpinned, terminal is resized, or zoom is toggled. This prevents codex CLI output corruption. - -**Files:** -- Modify: `internal/tui/app_pty.go` (rebalancePTYSizes handles zoom) -- Modify: `internal/tui/pty_session_test.go` - -**Step 1: Write the failing test** - -Add to `internal/tui/pty_session_test.go`: - -```go -func TestPTYSessionResizeUpdatesEmulatorDimensions(t *testing.T) { - session := NewPTYSession(PTYSessionConfig{ - ThreadID: "t-1", - Command: "true", - Args: nil, - SendMsg: func(msg PTYOutputMsg) {}, - }) - - session.Resize(100, 30) - if session.emulator.Width() != 100 { - t.Errorf("expected width 100, got %d", session.emulator.Width()) - } - if session.emulator.Height() != 30 { - t.Errorf("expected height 30, got %d", session.emulator.Height()) - } -} -``` - -**Step 2: Run test to verify it passes (existing behavior)** - -Run: `go test ./internal/tui -run TestPTYSessionResizeUpdatesEmulatorDimensions -v` -Expected: PASS (this confirms existing resize works) - -**Step 3: Update rebalancePTYSizes to handle zoom** - -In `internal/tui/app_pty.go`: - -```go -func (app AppModel) rebalancePTYSizes() tea.Cmd { - pinned := app.sessionPanel.PinnedSessions() - if len(pinned) == 0 { - return nil - } - - canvasHeight := int(float64(app.height) * app.sessionPanel.SplitRatio()) - panelHeight := app.height - canvasHeight - dividerHeight - - if app.sessionPanel.Zoomed() { - activeID := app.sessionPanel.ActiveThreadID() - ptySession, exists := app.ptySessions[activeID] - if exists { - ptySession.Resize(app.width, panelHeight) - } - return nil - } - - count := len(pinned) - sessionWidth := app.width / count - for _, threadID := range pinned { - ptySession, exists := app.ptySessions[threadID] - if exists { - ptySession.Resize(sessionWidth, panelHeight) - } - } - return nil -} -``` - -**Step 4: Run full test suite** - -Run: `go test ./... -v -race` -Expected: All tests pass - -**Step 5: Commit** - -```bash -git add internal/tui/app_pty.go internal/tui/pty_session_test.go -git commit -m "fix(tui): PTY resize handles zoom mode and multi-session rebalancing" -``` - ---- - -## Task 13: Final cleanup and file length check - -Ensure all files are under 300 lines, functions under 60 lines, and linter passes. - -**Files:** -- All modified files - -**Step 1: Check file lengths** - -Run: `wc -l internal/tui/*.go | sort -rn | head -20` -Expected: All non-test files under 300 lines - -**Step 2: Run full test suite with race detector** - -Run: `go test ./... -v -race` -Expected: All tests pass - -**Step 3: Run linter** - -Run: `golangci-lint run` -Expected: No issues - -**Step 4: If any file exceeds 300 lines** - -Extract functions into new files. For example, if `app.go` is too long, move `handleArrow`, `handleCanvasArrow`, `handleTreeArrow` into `app_nav.go`. - -If `app_pty.go` is too long, consider moving `rebalancePTYSizes` and `pinnedIndex` into `app_panel.go`. - -**Step 5: Final commit** - -```bash -git add -A -git commit -m "chore(tui): file length cleanup, all files under 300 lines" -``` - ---- - -## Summary - -| Task | Description | New Files | Modified Files | -|------|-------------|-----------|---------------| -| 1 | Pin/unpin/focus message types | — | msgs.go, msgs_test.go | -| 2 | SessionPanelModel sub-model | session_panel.go, session_panel_test.go | — | -| 3 | Integrate panel into AppModel | — | app.go, app_view.go, app_pty.go, app_test.go | -| 4 | Space key toggles pin | — | app.go, app_pty.go, app_test.go | -| 5 | Tab key switches focus | — | app.go, app_test.go | -| 6 | Ctrl+B x/z prefix actions | — | app_menu.go, session_panel.go, session_panel_test.go, app_test.go | -| 7 | Zoom mode rendering | — | app_view.go, app_test.go | -| 8 | Updated help overlay | — | help.go, app_test.go | -| 9 | Remove orphaned SessionModel | — | session.go (delete), session_test.go (delete) | -| 10 | Labeled divider bar | divider.go, divider_test.go | app_view.go | -| 11 | Ctrl+B arrows/1-9 navigation | — | prefix.go, app_menu.go, app_test.go | -| 12 | PTY resize on layout changes | — | app_pty.go, pty_session_test.go | -| 13 | Final cleanup + lint | — | any files over 300 lines | diff --git a/docs/plans/2026-03-18-sub-agent-visualization-design.md b/docs/plans/2026-03-18-sub-agent-visualization-design.md deleted file mode 100644 index 886c013..0000000 --- a/docs/plans/2026-03-18-sub-agent-visualization-design.md +++ /dev/null @@ -1,171 +0,0 @@ -# Sub-Agent Visualization Design - -## Goal - -Visualize Codex sub-agent hierarchies on DJ's canvas grid by migrating to the v2 app-server protocol and rendering parent-child connectors between cards. - -## Context - -Codex CLI (v0.42+) spawns sub-agents that appear as separate threads linked by `parent_thread_id`. The v2 app-server protocol emits 10 collaboration events covering the full sub-agent lifecycle. DJ currently uses the legacy protocol format and silently drops unknown events, missing all collaboration data. - -## Design - -### 1. Protocol Layer Migration - -Replace the legacy `{id, msg: {type}}` envelope with JSON-RPC 2.0 `{method, params}`. - -**Envelope change in `appserver/protocol.go`:** - -The current `ProtoEvent` and `EventHeader` types are replaced with a `JsonRpcMessage` that handles notifications (no `id`), requests (`id` + `method`), and responses (`id` + `result`/`error`). - -**Method constants in `appserver/methods.go`:** - -| Legacy | V2 | -|---------------------------|--------------------------------------------| -| `session_configured` | `thread/started` | -| `task_started` | `turn/started` | -| `task_complete` | `turn/completed` | -| `agent_message_delta` | `item/agentMessage/delta` | -| `agent_message` | `item/completed` (agent message item) | -| `exec_command_request` | `item/commandExecution/requestApproval` | -| `patch_apply_request` | `item/fileChange/requestApproval` | - -New methods for collaboration: -- `thread/started` (with `SubAgent` source) -- `thread/status/changed` -- `item/started` / `item/completed` (for `CollabAgentToolCall` items) - -**Client ReadLoop (`client.go`):** - -Parse the JSON-RPC envelope and route by `method` field. - -**New types in `appserver/types_collab.go`:** - -Types for 10 collaboration events: -- `CollabAgentSpawnBeginEvent` / `CollabAgentSpawnEndEvent` — carries `sender_thread_id`, `new_thread_id`, `agent_nickname`, `agent_role`, `depth` -- `CollabAgentInteractionBeginEvent` / `CollabAgentInteractionEndEvent` — carries `sender_thread_id`, `receiver_thread_id`, `prompt` -- `CollabWaitingBeginEvent` / `CollabWaitingEndEvent` — carries `sender_thread_id`, `receiver_thread_ids` -- `CollabCloseBeginEvent` / `CollabCloseEndEvent` — carries `sender_thread_id`, `receiver_thread_id` -- `CollabResumeBeginEvent` / `CollabResumeEndEvent` — carries `sender_thread_id`, `receiver_thread_id` - -Supporting types: `SubAgentSource`, `SessionSource`, `AgentStatus`, `CollabAgentTool`, `CollabAgentToolCallStatus`. - -### 2. State Layer Extensions - -**`ThreadState` new fields:** - -``` -AgentNickname string // from thread.agent_nickname -AgentRole string // from thread.agent_role -Depth int // nesting level (0 = root) -Model string // model used by this thread -``` - -**Parent-child wiring:** - -When `thread/started` arrives with `source: SubAgent(ThreadSpawn{parent_thread_id, depth, ...})`, call `store.AddWithParent()` and populate the new fields. - -**Status mapping:** - -| AgentStatus | DJ Status | -|---------------|-------------| -| PendingInit | idle | -| Running | active | -| Interrupted | idle | -| Completed | completed | -| Errored | error | -| Shutdown | completed | - -**Tree ordering:** - -New `store.TreeOrder()` method returns threads in depth-first order (roots first, then children recursively). Same traversal as `TreeModel.rebuild()`. - -### 3. Canvas Edge Rendering - -**Layout with connectors:** - -The grid renders threads in tree order. Between grid rows that have parent-child relationships, a connector row is inserted using box-drawing characters. - -``` -+------------+ +------------+ +------------+ -| Main | | Other | | Another | -+-----+------+ +------------+ +------------+ - | - +---------------------------+ - | | -+-----+------+ +------------+ +-+----------+ -| Sub-1 | | | | Sub-2 | -+------------+ +------------+ +------------+ -``` - -**Implementation in `canvas_edges.go`:** - -`renderConnectorRow(parentPositions, childPositions, cardWidth, columnGap) string`: -1. Find horizontal center of each card by column -2. For each parent-child pair, draw `|` down from parent center -3. Draw horizontal `─` to each child center -4. Use `┬` at parent, `├`/`┤` at branches, `┴` at children - -**Edge styling:** -- Color by parent status (green if active, gray if idle) -- Dim edges to completed/errored children - -### 4. Card Enhancements - -Sub-agent cards display: -- `↳` prefix on title to indicate child status -- Agent role as subtitle line -- Same status color coding as root cards - -``` -+----------------+ -| ↳ Sub-Agent | -| researcher | -| active | -+----------------+ -``` - -### 5. Multi-Thread Protocol Routing - -Eliminate global `sessionID`. Every Bubble Tea message carries `ThreadID` from the v2 protocol's per-notification `thread_id` field. - -App handlers look up the correct `ThreadState` by ID: - -``` -handleTurnStarted(msg): - store.UpdateStatus(msg.ThreadID, active, "") - -handleAgentDelta(msg): - thread = store.Get(msg.ThreadID) - thread.AppendDelta(msg.MessageID, msg.Delta) -``` - -### 6. Bridge Routing - -`tui/bridge.go` switches on v2 `method` strings instead of legacy `type` strings. Decode functions extract `thread_id` and event-specific data into typed Bubble Tea messages. - -## Out of Scope - -- DAG/pipeline visualization (parent-child tree only) -- Drag-and-drop card rearrangement -- Custom edge styling beyond status colors -- Collaboration event replay/history -- Manual thread linking UI - -## Files Touched - -- `internal/appserver/protocol.go` — JSON-RPC envelope types -- `internal/appserver/methods.go` — v2 method constants -- `internal/appserver/types_thread.go` — v2 thread/turn types -- `internal/appserver/types_collab.go` — new collaboration types -- `internal/appserver/client.go` — ReadLoop v2 parsing -- `internal/state/thread.go` — new ThreadState fields -- `internal/state/store.go` — TreeOrder() method -- `internal/tui/bridge.go` — v2 method routing -- `internal/tui/messages.go` — new Bubble Tea messages -- `internal/tui/canvas.go` — tree-ordered rendering -- `internal/tui/canvas_edges.go` — new connector rendering -- `internal/tui/card.go` — sub-agent display enhancements -- `internal/tui/app.go` — multi-thread routing -- `internal/tui/app_proto.go` — new event handlers -- Tests for all above diff --git a/docs/plans/2026-03-18-sub-agent-visualization-plan.md b/docs/plans/2026-03-18-sub-agent-visualization-plan.md deleted file mode 100644 index 3036a99..0000000 --- a/docs/plans/2026-03-18-sub-agent-visualization-plan.md +++ /dev/null @@ -1,1918 +0,0 @@ -# Sub-Agent Visualization Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Visualize Codex sub-agent hierarchies on DJ's canvas grid with parent-child connectors by migrating to the v2 app-server protocol. - -**Architecture:** Replace the legacy `{id, msg: {type}}` protocol with JSON-RPC 2.0 `{method, params}` in the appserver package. Wire collaboration events (`CollabAgentSpawn*`) into ThreadStore parent-child relationships. Render tree-ordered cards with box-drawing connectors on the canvas. - -**Tech Stack:** Go 1.25, Bubble Tea, Lipgloss, JSON-RPC 2.0 over stdio (JSONL) - -**Design doc:** `docs/plans/2026-03-18-sub-agent-visualization-design.md` - -**CI constraints:** funlen 60 lines, cyclop 15, file max 300 lines (non-test), `go test -race` - ---- - -## Phase 1: Protocol Types - -### Task 1: JSON-RPC Envelope Types - -Replace `ProtoEvent` / `EventHeader` / `ProtoSubmission` with JSON-RPC 2.0 types. - -**Files:** -- Modify: `internal/appserver/protocol.go` -- Test: `internal/appserver/protocol_test.go` - -**Step 1: Write failing tests for JSON-RPC parsing** - -```go -// protocol_test.go -package appserver - -import ( - "encoding/json" - "testing" -) - -func TestParseNotification(t *testing.T) { - raw := `{"jsonrpc":"2.0","method":"thread/started","params":{"thread_id":"t-1"}}` - var message JsonRpcMessage - if err := json.Unmarshal([]byte(raw), &message); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if message.Method != "thread/started" { - t.Errorf("expected thread/started, got %s", message.Method) - } - if message.IsRequest() { - t.Error("notification should not be a request") - } -} - -func TestParseRequest(t *testing.T) { - raw := `{"jsonrpc":"2.0","id":"req-1","method":"item/commandExecution/requestApproval","params":{"command":"ls"}}` - var message JsonRpcMessage - if err := json.Unmarshal([]byte(raw), &message); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if message.ID != "req-1" { - t.Errorf("expected req-1, got %s", message.ID) - } - if !message.IsRequest() { - t.Error("should be a request") - } -} - -func TestParseResponse(t *testing.T) { - raw := `{"jsonrpc":"2.0","id":"dj-1","result":{"ok":true}}` - var message JsonRpcMessage - if err := json.Unmarshal([]byte(raw), &message); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if !message.IsResponse() { - t.Error("should be a response") - } -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `go test ./internal/appserver -run TestParse -v` -Expected: Compile error — `JsonRpcMessage` undefined - -**Step 3: Implement JSON-RPC types** - -Replace `protocol.go` contents: - -```go -package appserver - -import "encoding/json" - -// JsonRpcMessage represents a JSON-RPC 2.0 message (notification, request, or response). -type JsonRpcMessage struct { - JsonRpc string `json:"jsonrpc"` - ID string `json:"id,omitempty"` - Method string `json:"method,omitempty"` - Params json.RawMessage `json:"params,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error json.RawMessage `json:"error,omitempty"` -} - -// IsRequest returns true if this message is a server-to-client request. -func (message JsonRpcMessage) IsRequest() bool { - return message.ID != "" && message.Method != "" -} - -// IsResponse returns true if this message is a response to a client request. -func (message JsonRpcMessage) IsResponse() bool { - return message.ID != "" && message.Method == "" -} - -// IsNotification returns true if this message is a server notification. -func (message JsonRpcMessage) IsNotification() bool { - return message.ID == "" && message.Method != "" -} - -// JsonRpcRequest is an outgoing client-to-server request. -type JsonRpcRequest struct { - JsonRpc string `json:"jsonrpc"` - ID string `json:"id"` - Method string `json:"method"` - Params interface{} `json:"params,omitempty"` -} - -// JsonRpcResponse is an outgoing client response to a server request. -type JsonRpcResponse struct { - JsonRpc string `json:"jsonrpc"` - ID string `json:"id"` - Result interface{} `json:"result"` -} -``` - -Note: Keep `ProtoEvent`, `EventHeader`, and `ProtoSubmission` temporarily as deprecated aliases (removed after bridge migration in Task 7). This prevents breaking all existing code at once. - -```go -// Deprecated: Legacy protocol types kept during migration. -type ProtoEvent = JsonRpcMessage -type EventHeader struct { - Type string `json:"type"` -} -type ProtoSubmission = JsonRpcRequest -``` - -**Step 4: Run tests to verify they pass** - -Run: `go test ./internal/appserver -run TestParse -v` -Expected: PASS - -**Step 5: Run full test suite to verify no regressions** - -Run: `go test ./... -v -race` -Expected: All pass (aliases maintain backward compat) - -**Step 6: Commit** - -``` -git add internal/appserver/protocol.go internal/appserver/protocol_test.go -git commit -m "feat: add JSON-RPC 2.0 envelope types for v2 protocol" -``` - ---- - -### Task 2: V2 Method Constants - -Replace legacy event type strings with v2 method strings. - -**Files:** -- Modify: `internal/appserver/methods.go` - -**Step 1: Add v2 method constants alongside legacy ones** - -```go -package appserver - -// Legacy event types (deprecated — remove after bridge migration). -const ( - EventSessionConfigured = "session_configured" - EventTaskStarted = "task_started" - EventTaskComplete = "task_complete" - EventAgentMessage = "agent_message" - EventAgentMessageDelta = "agent_message_delta" - EventAgentReasoning = "agent_reasoning" - EventAgentReasonDelta = "agent_reasoning_delta" - EventTokenCount = "token_count" - EventExecApproval = "exec_command_request" - EventPatchApproval = "patch_apply_request" - EventAgentReasonBreak = "agent_reasoning_section_break" -) - -// V2 server notification methods. -const ( - MethodThreadStarted = "thread/started" - MethodThreadStatusChanged = "thread/status/changed" - MethodThreadClosed = "thread/closed" - MethodTurnStarted = "turn/started" - MethodTurnCompleted = "turn/completed" - MethodItemStarted = "item/started" - MethodItemCompleted = "item/completed" - MethodAgentMessageDelta = "item/agentMessage/delta" - MethodTokenUsageUpdated = "thread/tokenUsage/updated" - MethodExecOutputDelta = "item/commandExecution/outputDelta" - MethodErrorNotification = "error" -) - -// V2 server request methods (require response). -const ( - MethodExecApproval = "item/commandExecution/requestApproval" - MethodFileApproval = "item/fileChange/requestApproval" -) - -// V2 client request methods (outgoing). -const ( - MethodInitialize = "initialize" - MethodThreadStart = "thread/start" - MethodTurnStart = "turn/start" - MethodTurnInterrupt = "turn/interrupt" -) - -// Legacy operation types (deprecated — remove after client migration). -const ( - OpUserInput = "user_input" - OpInterrupt = "interrupt" - OpExecApproval = "exec_approval" - OpPatchApproval = "patch_approval" - OpShutdown = "shutdown" -) -``` - -**Step 2: Run full test suite** - -Run: `go test ./... -v -race` -Expected: All pass (additive change) - -**Step 3: Commit** - -``` -git add internal/appserver/methods.go -git commit -m "feat: add v2 JSON-RPC method constants" -``` - ---- - -### Task 3: V2 Thread & Turn Types - -Replace `SessionConfigured`, `TaskStarted`, `TaskComplete` with v2 types. - -**Files:** -- Modify: `internal/appserver/types_thread.go` -- Test: `internal/appserver/types_thread_test.go` - -**Step 1: Write failing tests** - -```go -package appserver - -import ( - "encoding/json" - "testing" -) - -func TestUnmarshalThreadStarted(t *testing.T) { - raw := `{ - "thread": { - "id": "t-1", - "status": "idle", - "source": {"type": "sub_agent", "parent_thread_id": "t-0", "depth": 1, "agent_nickname": "scout", "agent_role": "researcher"} - } - }` - var notification ThreadStartedNotification - if err := json.Unmarshal([]byte(raw), ¬ification); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if notification.Thread.ID != "t-1" { - t.Errorf("expected t-1, got %s", notification.Thread.ID) - } - if notification.Thread.Source.Type != SourceTypeSubAgent { - t.Errorf("expected sub_agent source, got %s", notification.Thread.Source.Type) - } - if notification.Thread.Source.ParentThreadID != "t-0" { - t.Errorf("expected parent t-0, got %s", notification.Thread.Source.ParentThreadID) - } -} - -func TestUnmarshalThreadStartedCLISource(t *testing.T) { - raw := `{"thread": {"id": "t-1", "status": "idle", "source": {"type": "cli"}}}` - var notification ThreadStartedNotification - if err := json.Unmarshal([]byte(raw), ¬ification); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if notification.Thread.Source.Type != SourceTypeCLI { - t.Errorf("expected cli source, got %s", notification.Thread.Source.Type) - } -} - -func TestUnmarshalTurnStarted(t *testing.T) { - raw := `{"thread_id": "t-1", "turn": {"id": "turn-1", "status": "in_progress"}}` - var notification TurnStartedNotification - if err := json.Unmarshal([]byte(raw), ¬ification); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if notification.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", notification.ThreadID) - } -} - -func TestUnmarshalTurnCompleted(t *testing.T) { - raw := `{"thread_id": "t-1", "turn": {"id": "turn-1", "status": "completed"}}` - var notification TurnCompletedNotification - if err := json.Unmarshal([]byte(raw), ¬ification); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if notification.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", notification.ThreadID) - } -} -``` - -**Step 2: Run to verify failure** - -Run: `go test ./internal/appserver -run TestUnmarshal -v` -Expected: Compile error — types undefined - -**Step 3: Implement v2 types** - -Rewrite `types_thread.go`: - -```go -package appserver - -const ( - SourceTypeCLI = "cli" - SourceTypeSubAgent = "sub_agent" - SourceTypeExec = "exec" -) - -const ( - ThreadStatusIdle = "idle" - ThreadStatusActive = "active" -) - -type SessionSource struct { - Type string `json:"type"` - ParentThreadID string `json:"parent_thread_id,omitempty"` - Depth int `json:"depth,omitempty"` - AgentNickname string `json:"agent_nickname,omitempty"` - AgentRole string `json:"agent_role,omitempty"` -} - -type Thread struct { - ID string `json:"id"` - Status string `json:"status"` - Source SessionSource `json:"source"` -} - -type ThreadStartedNotification struct { - Thread Thread `json:"thread"` -} - -type ThreadStatusChangedNotification struct { - ThreadID string `json:"thread_id"` - Status string `json:"status"` -} - -type TurnStartedNotification struct { - ThreadID string `json:"thread_id"` - Turn Turn `json:"turn"` -} - -type TurnCompletedNotification struct { - ThreadID string `json:"thread_id"` - Turn Turn `json:"turn"` -} - -type Turn struct { - ID string `json:"id"` - Status string `json:"status"` -} - -type AgentMessageDeltaNotification struct { - ThreadID string `json:"thread_id"` - Delta string `json:"delta"` -} - -type ItemCompletedNotification struct { - ThreadID string `json:"thread_id"` - Item Item `json:"item"` -} - -type Item struct { - ID string `json:"id"` - Type string `json:"type"` -} -``` - -Keep legacy types at bottom with deprecation note: - -```go -// Deprecated: Legacy types kept during migration. Remove after bridge migration. -type SessionConfigured struct { - SessionID string `json:"session_id"` - Model string `json:"model"` - ReasoningEffort string `json:"reasoning_effort"` - HistoryLogID int64 `json:"history_log_id"` - RolloutPath string `json:"rollout_path"` -} - -type TaskStarted struct { - ModelContextWindow int `json:"model_context_window"` -} - -type TaskComplete struct { - LastAgentMessage string `json:"last_agent_message"` -} - -type AgentMessage struct { - Message string `json:"message"` -} - -type AgentDelta struct { - Delta string `json:"delta"` -} -``` - -Note: This file may exceed 300 lines with legacy types. Split the legacy types into `types_legacy.go` if needed for CI. - -**Step 4: Run tests** - -Run: `go test ./internal/appserver -run TestUnmarshal -v` -Expected: PASS - -**Step 5: Run full suite** - -Run: `go test ./... -v -race` -Expected: All pass - -**Step 6: Commit** - -``` -git add internal/appserver/types_thread.go internal/appserver/types_thread_test.go -git commit -m "feat: add v2 thread and turn notification types" -``` - ---- - -### Task 4: V2 Collaboration Types - -New types for the 10 collaboration events. - -**Files:** -- Create: `internal/appserver/types_collab.go` -- Test: `internal/appserver/types_collab_test.go` - -**Step 1: Write failing tests** - -```go -package appserver - -import ( - "encoding/json" - "testing" -) - -func TestUnmarshalCollabSpawnEnd(t *testing.T) { - raw := `{ - "call_id": "call-1", - "sender_thread_id": "t-0", - "new_thread_id": "t-1", - "new_agent_nickname": "scout", - "new_agent_role": "researcher", - "status": "running" - }` - var event CollabSpawnEndEvent - if err := json.Unmarshal([]byte(raw), &event); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if event.SenderThreadID != "t-0" { - t.Errorf("expected t-0, got %s", event.SenderThreadID) - } - if event.NewThreadID != "t-1" { - t.Errorf("expected t-1, got %s", event.NewThreadID) - } - if event.Status != AgentStatusRunning { - t.Errorf("expected running, got %s", event.Status) - } -} - -func TestUnmarshalCollabWaitingEnd(t *testing.T) { - raw := `{ - "sender_thread_id": "t-0", - "call_id": "call-2", - "statuses": {"t-1": "completed", "t-2": "running"} - }` - var event CollabWaitingEndEvent - if err := json.Unmarshal([]byte(raw), &event); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if len(event.Statuses) != 2 { - t.Errorf("expected 2 statuses, got %d", len(event.Statuses)) - } -} - -func TestUnmarshalCollabCloseEnd(t *testing.T) { - raw := `{ - "call_id": "call-3", - "sender_thread_id": "t-0", - "receiver_thread_id": "t-1", - "receiver_agent_nickname": "scout", - "receiver_agent_role": "researcher", - "status": "shutdown" - }` - var event CollabCloseEndEvent - if err := json.Unmarshal([]byte(raw), &event); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if event.ReceiverThreadID != "t-1" { - t.Errorf("expected t-1, got %s", event.ReceiverThreadID) - } - if event.Status != AgentStatusShutdown { - t.Errorf("expected shutdown, got %s", event.Status) - } -} -``` - -**Step 2: Run to verify failure** - -Run: `go test ./internal/appserver -run TestUnmarshalCollab -v` -Expected: Compile error - -**Step 3: Implement collaboration types** - -Create `types_collab.go`: - -```go -package appserver - -const ( - AgentStatusPendingInit = "pending_init" - AgentStatusRunning = "running" - AgentStatusInterrupted = "interrupted" - AgentStatusCompleted = "completed" - AgentStatusErrored = "errored" - AgentStatusShutdown = "shutdown" -) - -type CollabSpawnBeginEvent struct { - CallID string `json:"call_id"` - SenderThreadID string `json:"sender_thread_id"` - Prompt string `json:"prompt,omitempty"` - Model string `json:"model,omitempty"` -} - -type CollabSpawnEndEvent struct { - CallID string `json:"call_id"` - SenderThreadID string `json:"sender_thread_id"` - NewThreadID string `json:"new_thread_id"` - NewAgentNickname string `json:"new_agent_nickname,omitempty"` - NewAgentRole string `json:"new_agent_role,omitempty"` - Status string `json:"status"` -} - -type CollabInteractionBeginEvent struct { - CallID string `json:"call_id"` - SenderThreadID string `json:"sender_thread_id"` - ReceiverThreadID string `json:"receiver_thread_id"` - Prompt string `json:"prompt,omitempty"` -} - -type CollabInteractionEndEvent struct { - CallID string `json:"call_id"` - SenderThreadID string `json:"sender_thread_id"` - ReceiverThreadID string `json:"receiver_thread_id"` - ReceiverAgentNickname string `json:"receiver_agent_nickname,omitempty"` - ReceiverAgentRole string `json:"receiver_agent_role,omitempty"` - Status string `json:"status"` -} - -type CollabWaitingBeginEvent struct { - CallID string `json:"call_id"` - SenderThreadID string `json:"sender_thread_id"` - ReceiverThreadIDs []string `json:"receiver_thread_ids"` -} - -type CollabWaitingEndEvent struct { - CallID string `json:"call_id"` - SenderThreadID string `json:"sender_thread_id"` - Statuses map[string]string `json:"statuses"` -} - -type CollabCloseBeginEvent struct { - CallID string `json:"call_id"` - SenderThreadID string `json:"sender_thread_id"` - ReceiverThreadID string `json:"receiver_thread_id"` -} - -type CollabCloseEndEvent struct { - CallID string `json:"call_id"` - SenderThreadID string `json:"sender_thread_id"` - ReceiverThreadID string `json:"receiver_thread_id"` - ReceiverAgentNickname string `json:"receiver_agent_nickname,omitempty"` - ReceiverAgentRole string `json:"receiver_agent_role,omitempty"` - Status string `json:"status"` -} - -type CollabResumeBeginEvent struct { - CallID string `json:"call_id"` - SenderThreadID string `json:"sender_thread_id"` - ReceiverThreadID string `json:"receiver_thread_id"` - ReceiverAgentNickname string `json:"receiver_agent_nickname,omitempty"` - ReceiverAgentRole string `json:"receiver_agent_role,omitempty"` -} - -type CollabResumeEndEvent struct { - CallID string `json:"call_id"` - SenderThreadID string `json:"sender_thread_id"` - ReceiverThreadID string `json:"receiver_thread_id"` - ReceiverAgentNickname string `json:"receiver_agent_nickname,omitempty"` - ReceiverAgentRole string `json:"receiver_agent_role,omitempty"` - Status string `json:"status"` -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/appserver -run TestUnmarshalCollab -v` -Expected: PASS - -**Step 5: Commit** - -``` -git add internal/appserver/types_collab.go internal/appserver/types_collab_test.go -git commit -m "feat: add v2 collaboration event types" -``` - ---- - -### Task 5: V2 Approval Types - -Add types for v2 server requests (command/file approval). - -**Files:** -- Create: `internal/appserver/types_approval.go` -- Test: `internal/appserver/types_approval_test.go` - -**Step 1: Write failing test** - -```go -package appserver - -import ( - "encoding/json" - "testing" -) - -func TestUnmarshalCommandApproval(t *testing.T) { - raw := `{"thread_id":"t-1","command":{"command":"ls -la","cwd":"/tmp"}}` - var request CommandApprovalRequest - if err := json.Unmarshal([]byte(raw), &request); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if request.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", request.ThreadID) - } - if request.Command.Command != "ls -la" { - t.Errorf("expected ls -la, got %s", request.Command.Command) - } -} -``` - -**Step 2: Run to verify failure** - -Run: `go test ./internal/appserver -run TestUnmarshalCommand -v` -Expected: Compile error - -**Step 3: Implement** - -```go -package appserver - -type CommandApprovalRequest struct { - ThreadID string `json:"thread_id"` - Command CommandDetails `json:"command"` -} - -type CommandDetails struct { - Command string `json:"command"` - Cwd string `json:"cwd,omitempty"` -} - -type FileChangeApprovalRequest struct { - ThreadID string `json:"thread_id"` - Patch string `json:"patch"` -} - -// Deprecated: Legacy approval types kept during migration. -type ExecCommandRequest struct { - Command string `json:"command"` - Cwd string `json:"cwd,omitempty"` -} - -type PatchApplyRequest struct { - Patch string `json:"patch"` -} - -type UserInputOp struct { - Type string `json:"type"` - Items []InputItem `json:"items"` -} - -type InputItem struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` -} -``` - -**Step 4: Run tests and commit** - -Run: `go test ./internal/appserver -v -race` -Expected: PASS - -``` -git add internal/appserver/types_approval.go internal/appserver/types_approval_test.go -git commit -m "feat: add v2 approval request types" -``` - ---- - -## Phase 2: Client & Bridge Migration - -### Task 6: Client ReadLoop V2 Parsing - -Update ReadLoop to parse JSON-RPC messages and route by type. - -**Files:** -- Modify: `internal/appserver/client.go` -- Modify: `internal/appserver/client_test.go` - -**Step 1: Write failing tests for v2 ReadLoop** - -Add to `client_test.go`: - -```go -func TestReadLoopParsesV2Notification(t *testing.T) { - reader, writer := io.Pipe() - client := &Client{ - scanner: bufio.NewScanner(reader), - } - client.scanner.Buffer(make([]byte, scannerBufferSize), scannerBufferSize) - - var received JsonRpcMessage - done := make(chan struct{}) - go func() { - client.ReadLoop(func(message JsonRpcMessage) { - received = message - close(done) - }) - }() - - line := `{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"t-1"}}}` + "\n" - writer.Write([]byte(line)) - writer.Close() - <-done - - if received.Method != "thread/started" { - t.Errorf("expected thread/started, got %s", received.Method) - } -} - -func TestReadLoopParsesV2Request(t *testing.T) { - reader, writer := io.Pipe() - client := &Client{ - scanner: bufio.NewScanner(reader), - } - client.scanner.Buffer(make([]byte, scannerBufferSize), scannerBufferSize) - - var received JsonRpcMessage - done := make(chan struct{}) - go func() { - client.ReadLoop(func(message JsonRpcMessage) { - received = message - close(done) - }) - }() - - line := `{"jsonrpc":"2.0","id":"req-1","method":"item/commandExecution/requestApproval","params":{"command":"ls"}}` + "\n" - writer.Write([]byte(line)) - writer.Close() - <-done - - if received.ID != "req-1" { - t.Errorf("expected req-1, got %s", received.ID) - } - if !received.IsRequest() { - t.Error("should be a request") - } -} -``` - -**Step 2: Run to verify failure** - -Run: `go test ./internal/appserver -run TestReadLoopParsesV2 -v` -Expected: Compile error — ReadLoop signature expects `func(ProtoEvent)`, not `func(JsonRpcMessage)` - -**Step 3: Update ReadLoop and related methods** - -Change `ReadLoop` handler signature from `func(ProtoEvent)` to `func(JsonRpcMessage)`: - -```go -func (client *Client) ReadLoop(handler func(JsonRpcMessage)) { - for client.scanner.Scan() { - line := client.scanner.Bytes() - if len(line) == 0 { - continue - } - - var message JsonRpcMessage - if err := json.Unmarshal(line, &message); err != nil { - continue - } - - handler(message) - } -} -``` - -Update `Send` to use `JsonRpcRequest`: - -```go -func (client *Client) Send(request *JsonRpcRequest) error { - data, err := json.Marshal(request) - if err != nil { - return fmt.Errorf("marshal request: %w", err) - } - - client.mu.Lock() - defer client.mu.Unlock() - - data = append(data, '\n') - _, err = client.stdin.Write(data) - return err -} -``` - -Update `SendUserInput`, `SendInterrupt`, `SendApproval` to build `JsonRpcRequest` instead of `ProtoSubmission`. - -Also update the `OnEvent` callback type on the Client struct from `func(event ProtoEvent)` to `func(message JsonRpcMessage)`. - -**Step 4: Update all call sites** - -The `tui/app_proto.go` file references `ProtoEvent` in: -- `events chan appserver.ProtoEvent` → `events chan appserver.JsonRpcMessage` -- `protoEventMsg` wrapper struct -- `connectClient` goroutine -- `handleProtoEvent` method - -Update all of these. The bridge call in `handleProtoEvent` stays the same shape but takes `JsonRpcMessage` instead. - -**Step 5: Run full test suite** - -Run: `go test ./... -v -race` -Expected: All pass after updating all references - -**Step 6: Commit** - -``` -git add internal/appserver/client.go internal/appserver/client_test.go internal/tui/app.go internal/tui/app_proto.go -git commit -m "feat: migrate client ReadLoop to JSON-RPC 2.0 message format" -``` - ---- - -### Task 7: New Bubble Tea Messages - -Add thread-scoped message types for v2 events. - -**Files:** -- Modify: `internal/tui/msgs.go` - -**Step 1: Add v2 message types** - -Append to `msgs.go`: - -```go -type ThreadStartedMsg struct { - ThreadID string - Status string - SourceType string - ParentID string - Depth int - AgentNickname string - AgentRole string -} - -type ThreadStatusChangedMsg struct { - ThreadID string - Status string -} - -type TurnStartedMsg struct { - ThreadID string - TurnID string -} - -type TurnCompletedMsg struct { - ThreadID string - TurnID string -} - -type V2AgentDeltaMsg struct { - ThreadID string - Delta string -} - -type V2ExecApprovalMsg struct { - RequestID string - ThreadID string - Command string - Cwd string -} - -type V2FileApprovalMsg struct { - RequestID string - ThreadID string - Patch string -} - -type CollabSpawnMsg struct { - SenderThreadID string - NewThreadID string - NewAgentNickname string - NewAgentRole string - Status string -} - -type CollabCloseMsg struct { - SenderThreadID string - ReceiverThreadID string - Status string -} - -type CollabStatusUpdateMsg struct { - ThreadID string - Status string -} -``` - -**Step 2: Run full suite** - -Run: `go test ./... -v -race` -Expected: All pass (additive change) - -**Step 3: Commit** - -``` -git add internal/tui/msgs.go -git commit -m "feat: add v2 Bubble Tea message types with thread-scoped IDs" -``` - ---- - -### Task 8: Bridge V2 Routing - -Rewrite bridge to route on JSON-RPC method strings. - -**Files:** -- Modify: `internal/tui/bridge.go` -- Modify: `internal/tui/bridge_test.go` - -**Step 1: Write failing tests for v2 bridge** - -```go -func TestBridgeV2ThreadStarted(t *testing.T) { - message := appserver.JsonRpcMessage{ - Method: appserver.MethodThreadStarted, - Params: json.RawMessage(`{"thread":{"id":"t-1","status":"idle","source":{"type":"cli"}}}`), - } - msg := V2MessageToMsg(message) - started, ok := msg.(ThreadStartedMsg) - if !ok { - t.Fatalf("expected ThreadStartedMsg, got %T", msg) - } - if started.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", started.ThreadID) - } -} - -func TestBridgeV2SubAgentThread(t *testing.T) { - message := appserver.JsonRpcMessage{ - Method: appserver.MethodThreadStarted, - Params: json.RawMessage(`{"thread":{"id":"t-2","status":"idle","source":{"type":"sub_agent","parent_thread_id":"t-1","depth":1,"agent_nickname":"scout","agent_role":"researcher"}}}`), - } - msg := V2MessageToMsg(message) - started, ok := msg.(ThreadStartedMsg) - if !ok { - t.Fatalf("expected ThreadStartedMsg, got %T", msg) - } - if started.ParentID != "t-1" { - t.Errorf("expected parent t-1, got %s", started.ParentID) - } - if started.AgentRole != "researcher" { - t.Errorf("expected researcher, got %s", started.AgentRole) - } -} - -func TestBridgeV2TurnStarted(t *testing.T) { - message := appserver.JsonRpcMessage{ - Method: appserver.MethodTurnStarted, - Params: json.RawMessage(`{"thread_id":"t-1","turn":{"id":"turn-1","status":"in_progress"}}`), - } - msg := V2MessageToMsg(message) - turn, ok := msg.(TurnStartedMsg) - if !ok { - t.Fatalf("expected TurnStartedMsg, got %T", msg) - } - if turn.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", turn.ThreadID) - } -} - -func TestBridgeV2AgentDelta(t *testing.T) { - message := appserver.JsonRpcMessage{ - Method: appserver.MethodAgentMessageDelta, - Params: json.RawMessage(`{"thread_id":"t-1","delta":"hello"}`), - } - msg := V2MessageToMsg(message) - delta, ok := msg.(V2AgentDeltaMsg) - if !ok { - t.Fatalf("expected V2AgentDeltaMsg, got %T", msg) - } - if delta.Delta != "hello" { - t.Errorf("expected hello, got %s", delta.Delta) - } -} - -func TestBridgeV2UnknownMethodReturnsNil(t *testing.T) { - message := appserver.JsonRpcMessage{ - Method: "some/unknown/method", - } - msg := V2MessageToMsg(message) - if msg != nil { - t.Errorf("expected nil for unknown method, got %T", msg) - } -} -``` - -**Step 2: Run to verify failure** - -Run: `go test ./internal/tui -run TestBridgeV2 -v` -Expected: Compile error — `V2MessageToMsg` undefined - -**Step 3: Implement V2MessageToMsg** - -Add new function to `bridge.go` (keep legacy `ProtoEventToMsg` temporarily): - -```go -func V2MessageToMsg(message appserver.JsonRpcMessage) tea.Msg { - switch message.Method { - case appserver.MethodThreadStarted: - return decodeThreadStarted(message.Params) - case appserver.MethodTurnStarted: - return decodeTurnStarted(message.Params) - case appserver.MethodTurnCompleted: - return decodeTurnCompleted(message.Params) - case appserver.MethodAgentMessageDelta: - return decodeV2AgentDelta(message.Params) - case appserver.MethodThreadStatusChanged: - return decodeThreadStatusChanged(message.Params) - case appserver.MethodExecApproval: - return decodeV2ExecApproval(message) - case appserver.MethodFileApproval: - return decodeV2FileApproval(message) - } - return nil -} -``` - -Each decode function is a small helper. Example for `decodeThreadStarted`: - -```go -func decodeThreadStarted(raw json.RawMessage) tea.Msg { - var notification appserver.ThreadStartedNotification - if err := json.Unmarshal(raw, ¬ification); err != nil { - return nil - } - thread := notification.Thread - return ThreadStartedMsg{ - ThreadID: thread.ID, - Status: thread.Status, - SourceType: thread.Source.Type, - ParentID: thread.Source.ParentThreadID, - Depth: thread.Source.Depth, - AgentNickname: thread.Source.AgentNickname, - AgentRole: thread.Source.AgentRole, - } -} -``` - -Put each decode helper in its own small function. If `bridge.go` exceeds 300 lines, split v2 decoders into `bridge_v2.go`. - -**Step 4: Run tests** - -Run: `go test ./internal/tui -run TestBridgeV2 -v` -Expected: PASS - -**Step 5: Wire V2MessageToMsg into handleProtoEvent** - -In `app_proto.go`, update `handleProtoEvent` to call `V2MessageToMsg` instead of `ProtoEventToMsg`: - -```go -func (app AppModel) handleProtoEvent(message appserver.JsonRpcMessage) (tea.Model, tea.Cmd) { - tuiMsg := V2MessageToMsg(message) - if tuiMsg == nil { - return app, app.listenForEvents() - } - updatedApp, command := app.Update(tuiMsg) - nextListen := app.listenForEvents() - return updatedApp, tea.Batch(command, nextListen) -} -``` - -**Step 6: Run full suite and commit** - -Run: `go test ./... -v -race` -Expected: All pass - -``` -git add internal/tui/bridge.go internal/tui/bridge_test.go internal/tui/app_proto.go -git commit -m "feat: add v2 bridge routing with V2MessageToMsg" -``` - ---- - -## Phase 3: State Layer - -### Task 9: ThreadState Extensions - -Add sub-agent fields to ThreadState. - -**Files:** -- Modify: `internal/state/thread.go` -- Modify: `internal/state/store.go` -- Test: `internal/state/store_test.go` - -**Step 1: Write failing test** - -```go -func TestAddWithParentFields(t *testing.T) { - store := NewThreadStore() - store.Add("t-0", "Root") - store.AddWithParent("t-1", "Child", "t-0") - - child, exists := store.Get("t-1") - if !exists { - t.Fatal("child not found") - } - if child.ParentID != "t-0" { - t.Errorf("expected parent t-0, got %s", child.ParentID) - } -} - -func TestAddSubAgent(t *testing.T) { - store := NewThreadStore() - store.Add("t-0", "Root") - store.AddSubAgent("t-1", "Scout", "t-0", "scout", "researcher", 1) - - child, exists := store.Get("t-1") - if !exists { - t.Fatal("child not found") - } - if child.AgentNickname != "scout" { - t.Errorf("expected scout, got %s", child.AgentNickname) - } - if child.AgentRole != "researcher" { - t.Errorf("expected researcher, got %s", child.AgentRole) - } - if child.Depth != 1 { - t.Errorf("expected depth 1, got %d", child.Depth) - } -} -``` - -**Step 2: Run to verify failure** - -Run: `go test ./internal/state -run TestAddSubAgent -v` -Expected: Compile error — `AddSubAgent` undefined - -**Step 3: Add fields and method** - -In `thread.go`, add fields: - -```go -type ThreadState struct { - ID string - Title string - Status string - ParentID string - AgentNickname string - AgentRole string - Depth int - Model string - Messages []ChatMessage - CommandOutput map[string]string -} -``` - -In `store.go`, add: - -```go -func (store *ThreadStore) AddSubAgent(id string, title string, parentID string, nickname string, role string, depth int) { - store.mu.Lock() - defer store.mu.Unlock() - - thread := NewThreadState(id, title) - thread.ParentID = parentID - thread.AgentNickname = nickname - thread.AgentRole = role - thread.Depth = depth - store.threads[id] = thread - store.order = append(store.order, id) -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/state -v -race` -Expected: All PASS - -**Step 5: Commit** - -``` -git add internal/state/thread.go internal/state/store.go internal/state/store_test.go -git commit -m "feat: add sub-agent fields to ThreadState and AddSubAgent method" -``` - ---- - -### Task 10: Store TreeOrder Method - -Depth-first traversal for tree-ordered grid layout. - -**Files:** -- Modify: `internal/state/store.go` -- Test: `internal/state/store_test.go` - -**Step 1: Write failing test** - -```go -func TestTreeOrder(t *testing.T) { - store := NewThreadStore() - store.Add("root-1", "Root 1") - store.Add("root-2", "Root 2") - store.AddWithParent("child-1a", "Child 1a", "root-1") - store.AddWithParent("child-1b", "Child 1b", "root-1") - store.AddWithParent("child-2a", "Child 2a", "root-2") - - ordered := store.TreeOrder() - expectedOrder := []string{"root-1", "child-1a", "child-1b", "root-2", "child-2a"} - if len(ordered) != len(expectedOrder) { - t.Fatalf("expected %d threads, got %d", len(expectedOrder), len(ordered)) - } - for index, thread := range ordered { - if thread.ID != expectedOrder[index] { - t.Errorf("position %d: expected %s, got %s", index, expectedOrder[index], thread.ID) - } - } -} - -func TestTreeOrderNestedChildren(t *testing.T) { - store := NewThreadStore() - store.Add("root", "Root") - store.AddWithParent("child", "Child", "root") - store.AddWithParent("grandchild", "Grandchild", "child") - - ordered := store.TreeOrder() - expectedOrder := []string{"root", "child", "grandchild"} - for index, thread := range ordered { - if thread.ID != expectedOrder[index] { - t.Errorf("position %d: expected %s, got %s", index, expectedOrder[index], thread.ID) - } - } -} -``` - -**Step 2: Run to verify failure** - -Run: `go test ./internal/state -run TestTreeOrder -v` -Expected: Compile error - -**Step 3: Implement TreeOrder** - -Add to `store.go`: - -```go -func (store *ThreadStore) TreeOrder() []*ThreadState { - store.mu.RLock() - defer store.mu.RUnlock() - - var result []*ThreadState - for _, id := range store.order { - thread := store.threads[id] - if thread.ParentID == "" { - result = append(result, thread) - result = store.appendChildrenRecursive(result, id) - } - } - return result -} - -func (store *ThreadStore) appendChildrenRecursive(result []*ThreadState, parentID string) []*ThreadState { - for _, id := range store.order { - thread := store.threads[id] - if thread.ParentID == parentID { - result = append(result, thread) - result = store.appendChildrenRecursive(result, id) - } - } - return result -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/state -run TestTreeOrder -v` -Expected: PASS - -**Step 5: Commit** - -``` -git add internal/state/store.go internal/state/store_test.go -git commit -m "feat: add TreeOrder for depth-first thread traversal" -``` - ---- - -## Phase 4: Multi-Thread Event Routing - -### Task 11: Thread-Scoped App Handlers - -Remove global `sessionID`, route events by ThreadID. - -**Files:** -- Modify: `internal/tui/app.go` -- Modify: `internal/tui/app_proto.go` - -**Step 1: Add v2 handler functions** - -Add handlers for the new message types in `app_proto.go`: - -```go -func (app AppModel) handleThreadStarted(msg ThreadStartedMsg) (tea.Model, tea.Cmd) { - isSubAgent := msg.SourceType == appserver.SourceTypeSubAgent - if isSubAgent { - app.store.AddSubAgent(msg.ThreadID, msg.AgentNickname, msg.ParentID, msg.AgentNickname, msg.AgentRole, msg.Depth) - } else { - app.store.Add(msg.ThreadID, msg.ThreadID) - } - app.statusBar.SetThreadCount(len(app.store.All())) - app.tree.Refresh() - return app, nil -} - -func (app AppModel) handleTurnStarted(msg TurnStartedMsg) (tea.Model, tea.Cmd) { - app.store.UpdateStatus(msg.ThreadID, state.StatusActive, "") - return app, nil -} - -func (app AppModel) handleTurnCompleted(msg TurnCompletedMsg) (tea.Model, tea.Cmd) { - app.store.UpdateStatus(msg.ThreadID, state.StatusCompleted, "") - return app, nil -} - -func (app AppModel) handleV2AgentDelta(msg V2AgentDeltaMsg) (tea.Model, tea.Cmd) { - thread, exists := app.store.Get(msg.ThreadID) - if !exists { - return app, nil - } - thread.AppendDelta("", msg.Delta) - return app, nil -} - -func (app AppModel) handleCollabSpawn(msg CollabSpawnMsg) (tea.Model, tea.Cmd) { - app.tree.Refresh() - return app, nil -} - -func (app AppModel) handleCollabClose(msg CollabCloseMsg) (tea.Model, tea.Cmd) { - agentStatus := mapAgentStatusToDJ(msg.Status) - app.store.UpdateStatus(msg.ReceiverThreadID, agentStatus, "") - return app, nil -} -``` - -Add status mapping helper: - -```go -func mapAgentStatusToDJ(agentStatus string) string { - statusMap := map[string]string{ - appserver.AgentStatusPendingInit: state.StatusIdle, - appserver.AgentStatusRunning: state.StatusActive, - appserver.AgentStatusInterrupted: state.StatusIdle, - appserver.AgentStatusCompleted: state.StatusCompleted, - appserver.AgentStatusErrored: state.StatusError, - appserver.AgentStatusShutdown: state.StatusCompleted, - } - djStatus, exists := statusMap[agentStatus] - if !exists { - return state.StatusIdle - } - return djStatus -} -``` - -**Step 2: Wire into Update switch** - -In `app.go`'s `Update` method, add cases for new message types: - -```go -case ThreadStartedMsg: - return app.handleThreadStarted(msg) -case TurnStartedMsg: - return app.handleTurnStarted(msg) -case TurnCompletedMsg: - return app.handleTurnCompleted(msg) -case V2AgentDeltaMsg: - return app.handleV2AgentDelta(msg) -case CollabSpawnMsg: - return app.handleCollabSpawn(msg) -case CollabCloseMsg: - return app.handleCollabClose(msg) -case ThreadStatusChangedMsg: - agentStatus := mapAgentStatusToDJ(msg.Status) - app.store.UpdateStatus(msg.ThreadID, agentStatus, "") - return app, nil -``` - -**Step 3: Run full test suite** - -Run: `go test ./... -v -race` -Expected: All pass - -**Step 4: Commit** - -``` -git add internal/tui/app.go internal/tui/app_proto.go -git commit -m "feat: add thread-scoped v2 event handlers with multi-thread routing" -``` - ---- - -## Phase 5: Canvas Visualization - -### Task 12: Tree-Ordered Canvas Layout - -Switch canvas from insertion order to tree order. - -**Files:** -- Modify: `internal/tui/canvas.go` -- Test: `internal/tui/canvas_test.go` - -**Step 1: Write failing test** - -```go -func TestCanvasTreeOrder(t *testing.T) { - store := state.NewThreadStore() - store.Add("root", "Root") - store.AddWithParent("child-1", "Child 1", "root") - store.AddWithParent("child-2", "Child 2", "root") - - canvas := NewCanvasModel(store) - canvas.SetDimensions(120, 40) - - view := canvas.View() - rootIndex := strings.Index(view, "Root") - child1Index := strings.Index(view, "Child 1") - child2Index := strings.Index(view, "Child 2") - - if rootIndex == -1 || child1Index == -1 || child2Index == -1 { - t.Fatal("expected all threads to appear in view") - } - if rootIndex > child1Index { - t.Error("root should appear before child-1") - } - if child1Index > child2Index { - t.Error("child-1 should appear before child-2") - } -} -``` - -**Step 2: Run to verify failure** - -Run: `go test ./internal/tui -run TestCanvasTreeOrder -v` -Expected: May pass or fail depending on insertion order — the point is to verify tree order is used - -**Step 3: Switch to TreeOrder in canvas View** - -In `canvas.go`, change `View()`: - -```go -func (canvas *CanvasModel) View() string { - threads := canvas.store.TreeOrder() - if len(threads) == 0 { - return canvas.renderEmpty() - } - // ... -} -``` - -Also update `SelectedThreadID`, `MoveRight`, `MoveLeft`, `MoveDown`, `MoveUp` to use `TreeOrder()` instead of `All()`. - -**Step 4: Run tests** - -Run: `go test ./internal/tui -v -race` -Expected: All pass - -**Step 5: Commit** - -``` -git add internal/tui/canvas.go internal/tui/canvas_test.go -git commit -m "feat: switch canvas to tree-ordered thread layout" -``` - ---- - -### Task 13: Canvas Edge Connectors - -Draw box-drawing connectors between parent and child cards. - -**Files:** -- Create: `internal/tui/canvas_edges.go` -- Test: `internal/tui/canvas_edges_test.go` - -**Step 1: Write failing tests** - -```go -package tui - -import ( - "strings" - "testing" -) - -func TestRenderConnectorSimple(t *testing.T) { - parentCol := 0 - childCols := []int{0} - connector := renderConnectorRow(parentCol, childCols, 20, 2) - if !strings.Contains(connector, "│") { - t.Error("expected vertical connector") - } -} - -func TestRenderConnectorBranching(t *testing.T) { - parentCol := 0 - childCols := []int{0, 2} - connector := renderConnectorRow(parentCol, childCols, 20, 2) - if !strings.Contains(connector, "├") || !strings.Contains(connector, "─") { - t.Error("expected branching connector with horizontal lines") - } -} - -func TestRenderConnectorNoChildren(t *testing.T) { - parentCol := 0 - childCols := []int{} - connector := renderConnectorRow(parentCol, childCols, 20, 2) - if connector != "" { - t.Error("expected empty string for no children") - } -} -``` - -**Step 2: Run to verify failure** - -Run: `go test ./internal/tui -run TestRenderConnector -v` -Expected: Compile error - -**Step 3: Implement connector rendering** - -Create `canvas_edges.go`: - -```go -package tui - -import "strings" - -const ( - edgeVertical = "│" - edgeHorizontal = "─" - edgeTeeDown = "┬" - edgeTeeRight = "├" - edgeCornerRight = "┐" - edgeElbow = "└" -) - -func renderConnectorRow(parentCol int, childCols []int, cardWidth int, gap int) string { - if len(childCols) == 0 { - return "" - } - - cellWidth := cardWidth + gap - parentCenter := parentCol*cellWidth + cardWidth/2 - totalWidth := computeConnectorWidth(childCols, cellWidth, cardWidth) - - line := buildConnectorLine(parentCenter, childCols, cellWidth, cardWidth, totalWidth) - return line -} - -func computeConnectorWidth(childCols []int, cellWidth int, cardWidth int) int { - maxCol := 0 - for _, col := range childCols { - if col > maxCol { - maxCol = col - } - } - return maxCol*cellWidth + cardWidth -} - -func buildConnectorLine(parentCenter int, childCols []int, cellWidth int, cardWidth int, totalWidth int) string { - childCenters := make(map[int]bool) - for _, col := range childCols { - center := col*cellWidth + cardWidth/2 - childCenters[center] = true - } - - minCenter := totalWidth - maxCenter := 0 - for center := range childCenters { - if center < minCenter { - minCenter = center - } - if center > maxCenter { - maxCenter = center - } - } - - topLine := strings.Repeat(" ", parentCenter) + edgeVertical - spanStart := minCenter - spanEnd := maxCenter - if parentCenter < spanStart { - spanStart = parentCenter - } - if parentCenter > spanEnd { - spanEnd = parentCenter - } - - var bottomLine strings.Builder - for position := 0; position <= spanEnd; position++ { - isChildCenter := childCenters[position] - isParentCenter := position == parentCenter - isInSpan := position >= spanStart && position <= spanEnd - - character := resolveConnectorChar(position, isChildCenter, isParentCenter, isInSpan) - bottomLine.WriteString(character) - } - - return topLine + "\n" + bottomLine.String() -} - -func resolveConnectorChar(position int, isChild bool, isParent bool, inSpan bool) string { - if isParent && isChild { - return edgeTeeDown - } - if isParent { - return edgeTeeDown - } - if isChild && inSpan { - return edgeElbow - } - if isChild { - return edgeVertical - } - if inSpan { - return edgeHorizontal - } - return " " -} -``` - -Note: This is a starting implementation. Exact box-drawing logic may need refinement during development — the tests will guide the correct character choices for edge cases. - -**Step 4: Run tests** - -Run: `go test ./internal/tui -run TestRenderConnector -v` -Expected: PASS (may need tweaking of exact characters in tests to match implementation) - -**Step 5: Integrate into canvas.go renderGrid** - -In `canvas.go`, after rendering each row, check if any card in the current row is a parent of cards in the next row. If so, insert a connector row: - -```go -func (canvas *CanvasModel) renderGrid(threads []*state.ThreadState) string { - // ... existing card rendering logic ... - - // Between rows, insert connector lines for parent-child relationships - // Build position map: threadID -> column index - // For each row boundary, find parents above and children below - // Call renderConnectorRow for each parent -} -``` - -**Step 6: Run full suite and commit** - -Run: `go test ./... -v -race` -Expected: All pass - -``` -git add internal/tui/canvas_edges.go internal/tui/canvas_edges_test.go internal/tui/canvas.go -git commit -m "feat: add canvas edge connectors between parent and child cards" -``` - ---- - -### Task 14: Card Sub-Agent Display - -Show agent role and depth indicator on sub-agent cards. - -**Files:** -- Modify: `internal/tui/card.go` -- Test: `internal/tui/card_test.go` - -**Step 1: Write failing test** - -```go -func TestSubAgentCardShowsRole(t *testing.T) { - thread := state.NewThreadState("t-1", "Scout") - thread.ParentID = "t-0" - thread.AgentRole = "researcher" - - card := NewCardModel(thread, false, false) - card.SetSize(30, 6) - view := card.View() - - if !strings.Contains(view, "researcher") { - t.Error("expected agent role in card view") - } -} - -func TestSubAgentCardShowsDepthPrefix(t *testing.T) { - thread := state.NewThreadState("t-1", "Scout") - thread.ParentID = "t-0" - thread.Depth = 1 - - card := NewCardModel(thread, false, false) - card.SetSize(30, 6) - view := card.View() - - if !strings.Contains(view, "↳") { - t.Error("expected depth prefix ↳ in sub-agent card") - } -} - -func TestRootCardNoDepthPrefix(t *testing.T) { - thread := state.NewThreadState("t-0", "Root Session") - - card := NewCardModel(thread, false, false) - card.SetSize(30, 6) - view := card.View() - - if strings.Contains(view, "↳") { - t.Error("root card should not have depth prefix") - } -} -``` - -**Step 2: Run to verify failure** - -Run: `go test ./internal/tui -run TestSubAgent -v` -Expected: FAIL - -**Step 3: Update card View** - -In `card.go`, modify the `View()` method to add sub-agent info: - -```go -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 := card.buildTitle() - content := card.buildContent(title, statusLine) - - style := card.buildBorderStyle() - return style.Render(content) -} - -func (card CardModel) buildTitle() string { - titleMaxLen := card.width - cardBorderPadding - if card.pinned { - titleMaxLen -= len(pinnedIndicator) - } - - title := card.thread.Title - isSubAgent := card.thread.ParentID != "" - if isSubAgent { - title = subAgentPrefix + title - } - - title = truncate(title, titleMaxLen) - if card.pinned { - title += pinnedIndicator - } - return title -} -``` - -Add the constant: - -```go -const subAgentPrefix = "↳ " -``` - -Add role line for sub-agents: - -```go -func (card CardModel) buildContent(title string, statusLine string) string { - isSubAgent := card.thread.ParentID != "" - hasRole := isSubAgent && card.thread.AgentRole != "" - if hasRole { - roleLine := lipgloss.NewStyle(). - Foreground(colorIdle). - Render(" " + card.thread.AgentRole) - return fmt.Sprintf("%s\n%s\n%s", title, roleLine, statusLine) - } - return fmt.Sprintf("%s\n%s", title, statusLine) -} -``` - -**Step 4: Run tests** - -Run: `go test ./internal/tui -run TestSubAgent -v && go test ./internal/tui -run TestRootCard -v` -Expected: PASS - -**Step 5: Run full suite and commit** - -Run: `go test ./... -v -race` -Expected: All pass - -``` -git add internal/tui/card.go internal/tui/card_test.go -git commit -m "feat: show agent role and depth prefix on sub-agent cards" -``` - ---- - -## Phase 6: Cleanup - -### Task 15: Remove Legacy Protocol Types - -Remove deprecated types and legacy bridge function. - -**Files:** -- Modify: `internal/appserver/protocol.go` — remove `ProtoEvent`, `EventHeader`, `ProtoSubmission` aliases -- Modify: `internal/appserver/methods.go` — remove legacy event constants and op constants -- Modify: `internal/appserver/types_thread.go` — remove legacy `SessionConfigured`, `TaskStarted`, etc. -- Modify: `internal/tui/bridge.go` — remove `ProtoEventToMsg` and legacy decode functions -- Update tests: remove legacy bridge tests - -**Step 1: Remove all deprecated types and functions** - -Delete the legacy aliases from `protocol.go`, legacy constants from `methods.go`, legacy types from `types_thread.go`/`types_approval.go`, and the `ProtoEventToMsg` function from `bridge.go`. - -**Step 2: Fix any remaining compile errors** - -Grep for any remaining references to removed types and update them. - -**Step 3: Run full suite** - -Run: `go test ./... -v -race` -Expected: All pass - -**Step 4: Run linter** - -Run: `golangci-lint run` -Expected: Clean - -**Step 5: Commit** - -``` -git add -A -git commit -m "refactor: remove legacy protocol types after v2 migration" -``` - ---- - -### Task 16: File Length & Lint Compliance - -Verify all files meet CI constraints. - -**Step 1: Check file lengths** - -Run: `find internal -name '*.go' ! -name '*_test.go' -exec wc -l {} + | sort -n` -Expected: No non-test file exceeds 300 lines - -**Step 2: Check function lengths** - -Run: `golangci-lint run` -Expected: No funlen or cyclop violations - -**Step 3: Split any oversized files** - -If `bridge.go` exceeds 300 lines: split v2 decoders into `bridge_v2.go`. -If `app_proto.go` exceeds 300 lines: split v2 handlers into `app_proto_v2.go`. -If `types_thread.go` exceeds 300 lines: split by category. - -**Step 4: Final full suite** - -Run: `go test ./... -v -race && golangci-lint run` -Expected: All clean - -**Step 5: Commit if changes needed** - -``` -git add -A -git commit -m "refactor: split oversized files for CI compliance" -``` diff --git a/docs/plans/2026-03-18-tui-improvements-design.md b/docs/plans/2026-03-18-tui-improvements-design.md deleted file mode 100644 index de5a4f1..0000000 --- a/docs/plans/2026-03-18-tui-improvements-design.md +++ /dev/null @@ -1,678 +0,0 @@ -# TUI Improvements Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add a header shortcuts bar, make `n` spawn+open sessions, and make the TUI full-height with centered, scaled cards. - -**Architecture:** Three independent UI improvements to the Bubble Tea TUI. The header bar is a new render function. The `n` key change modifies `createThread` and `ThreadCreatedMsg` handling to chain into the existing `openSession` flow. The full-height layout changes card sizing from constants to dynamic functions and wraps the canvas grid in `lipgloss.Place` for centering. - -**Tech Stack:** Go, Bubble Tea, Lipgloss - ---- - -### Task 1: Header Bar — Test - -**Files:** -- Create: `internal/tui/header_test.go` - -**Step 1: Write the failing test** - -```go -package tui - -import ( - "strings" - "testing" -) - -func TestHeaderBarRendersTitle(t *testing.T) { - header := NewHeaderBar(80) - output := header.View() - - if !strings.Contains(output, "DJ") { - t.Errorf("expected title in header, got:\n%s", output) - } -} - -func TestHeaderBarRendersShortcuts(t *testing.T) { - header := NewHeaderBar(80) - output := header.View() - - if !strings.Contains(output, "n: new") { - t.Errorf("expected shortcut hints in header, got:\n%s", output) - } -} - -func TestHeaderBarFitsWidth(t *testing.T) { - header := NewHeaderBar(120) - output := header.View() - - lines := strings.Split(output, "\n") - for _, line := range lines { - if len(line) > 120 { - t.Errorf("header exceeds width 120: len=%d", len(line)) - } - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui -run TestHeaderBar -v` -Expected: FAIL — `NewHeaderBar` undefined - ---- - -### Task 2: Header Bar — Implementation - -**Files:** -- Create: `internal/tui/header.go` -- Modify: `internal/tui/app_view.go:10-16` (replace titleStyle and title rendering) - -**Step 1: Create header.go** - -```go -package tui - -import "github.com/charmbracelet/lipgloss" - -var ( - headerTitleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("39")) - headerHintStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")) -) - -const headerTitle = "DJ — Codex TUI Visualizer" - -var headerHints = []string{ - "n: new", - "Enter: open", - "?: help", - "t: tree", - "Ctrl+B: prefix", -} - -type HeaderBar struct { - width int -} - -func NewHeaderBar(width int) HeaderBar { - return HeaderBar{width: width} -} - -func (header *HeaderBar) SetWidth(width int) { - header.width = width -} - -func (header HeaderBar) View() string { - title := headerTitleStyle.Render(headerTitle) - - hints := "" - for index, hint := range headerHints { - if index > 0 { - hints += " " - } - hints += hint - } - renderedHints := headerHintStyle.Render(hints) - - return lipgloss.NewStyle().Width(header.width).Render( - lipgloss.JoinHorizontal(lipgloss.Top, title, " ", renderedHints), - ) -} -``` - -Wait — `lipgloss.Place` is better here for left/right alignment. Revised: - -```go -func (header HeaderBar) View() string { - title := headerTitleStyle.Render(headerTitle) - - hints := "" - for index, hint := range headerHints { - if index > 0 { - hints += " " - } - hints += hint - } - renderedHints := headerHintStyle.Render(hints) - - leftRight := title + renderedHints - return lipgloss.PlaceHorizontal(header.width, lipgloss.Left, title, - lipgloss.WithWhitespaceChars(" ")) + "\r" + - lipgloss.PlaceHorizontal(header.width, lipgloss.Right, renderedHints, - lipgloss.WithWhitespaceChars(" ")) -} -``` - -Actually, the simplest approach: render title left-aligned, render hints right-aligned, pad the gap with spaces. - -```go -func (header HeaderBar) View() string { - title := headerTitleStyle.Render(headerTitle) - - hints := "" - for index, hint := range headerHints { - if index > 0 { - hints += " " - } - hints += hint - } - renderedHints := headerHintStyle.Render(hints) - - gap := header.width - lipgloss.Width(title) - lipgloss.Width(renderedHints) - if gap < 1 { - gap = 1 - } - padding := lipgloss.NewStyle().Width(gap).Render("") - return title + padding + renderedHints -} -``` - -**Step 2: Update app_view.go** - -Remove the `titleStyle` variable (lines 10-13). Replace `title := titleStyle.Render(...)` with usage of the new HeaderBar. - -In `app_view.go`, change: -```go -// Remove: -var titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("39")). - MarginBottom(1) - -// In View(): -// Replace: title := titleStyle.Render("DJ — Codex TUI Visualizer") -// With: title := app.header.View() -``` - -Add `header HeaderBar` field to `AppModel` in `app.go:16-39`. Initialize in `NewAppModel`. Update width in the `tea.WindowSizeMsg` handler alongside `statusBar.SetWidth`. - -**Step 3: Run tests** - -Run: `go test ./internal/tui -run TestHeaderBar -v` -Expected: PASS - -**Step 4: Run all tests to check for regressions** - -Run: `go test ./internal/tui -v` -Expected: All pass (some tests check for "DJ" in view output — the title is still present) - -**Step 5: Commit** - -```bash -git add internal/tui/header.go internal/tui/header_test.go internal/tui/app.go internal/tui/app_view.go -git commit -m "feat: add header bar with keyboard shortcut hints" -``` - ---- - -### Task 3: `n` Key Spawns Session — Test - -**Files:** -- Modify: `internal/tui/app_test.go` - -**Step 1: Write the failing test** - -Add to `app_test.go`: - -```go -func TestAppNewThreadCreatesAndOpensSession(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store, WithInteractiveCommand("cat")) - app.width = 120 - app.height = 40 - - nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} - updated, cmd := app.Update(nKey) - app = updated.(AppModel) - - if cmd == nil { - t.Fatal("expected command from n key") - } - - msg := cmd() - updated, _ = app.Update(msg) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - threads := store.All() - if len(threads) != 1 { - t.Fatalf("expected 1 thread, got %d", len(threads)) - } - - if app.FocusPane() != FocusPaneSession { - t.Errorf("expected session focus after new thread, got %d", app.FocusPane()) - } - - if len(app.sessionPanel.PinnedSessions()) != 1 { - t.Errorf("expected 1 pinned session, got %d", len(app.sessionPanel.PinnedSessions())) - } -} - -func TestAppNewThreadIncrementsTitle(t *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store, WithInteractiveCommand("cat")) - app.width = 120 - app.height = 40 - - nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} - - updated, cmd := app.Update(nKey) - app = updated.(AppModel) - msg := cmd() - updated, _ = app.Update(msg) - app = updated.(AppModel) - - updated, cmd = app.Update(nKey) - app = updated.(AppModel) - msg = cmd() - updated, _ = app.Update(msg) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - threads := store.All() - if len(threads) != 2 { - t.Fatalf("expected 2 threads, got %d", len(threads)) - } - if threads[0].Title != "Session 1" { - t.Errorf("expected 'Session 1', got %s", threads[0].Title) - } - if threads[1].Title != "Session 2" { - t.Errorf("expected 'Session 2', got %s", threads[1].Title) - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `go test ./internal/tui -run TestAppNewThread -v` -Expected: FAIL — new thread doesn't open session or increment titles - ---- - -### Task 4: `n` Key Spawns Session — Implementation - -**Files:** -- Modify: `internal/tui/app.go:16-39` (add `sessionCounter` field) -- Modify: `internal/tui/app.go:128-132` (`ThreadCreatedMsg` handler) -- Modify: `internal/tui/app.go:187-197` (`createThread` function) - -**Step 1: Add sessionCounter to AppModel** - -In `app.go`, add `sessionCounter int` field to `AppModel` struct. - -**Step 2: Update createThread to generate proper IDs** - -Replace `createThread` (lines 187-197): - -```go -func (app *AppModel) createThread() tea.Cmd { - app.sessionCounter++ - counter := app.sessionCounter - return func() tea.Msg { - return ThreadCreatedMsg{ - ThreadID: fmt.Sprintf("session-%d", counter), - Title: fmt.Sprintf("Session %d", counter), - } - } -} -``` - -Note: this removes the `app.client == nil` guard — `n` always creates a local session now. Add `"fmt"` import if not present. - -**Step 3: Update ThreadCreatedMsg handler to chain into openSession** - -Replace the `ThreadCreatedMsg` case in `Update()` (lines 128-132): - -```go -case ThreadCreatedMsg: - app.store.Add(msg.ThreadID, msg.Title) - app.statusBar.SetThreadCount(len(app.store.All())) - app.canvas.SetSelected(len(app.store.All()) - 1) - return app.openSession() -``` - -**Step 4: Add SetSelected to CanvasModel** - -In `canvas.go`, add: - -```go -func (canvas *CanvasModel) SetSelected(index int) { - threads := canvas.store.All() - if index >= 0 && index < len(threads) { - canvas.selected = index - } -} -``` - -**Step 5: Run tests** - -Run: `go test ./internal/tui -run TestAppNewThread -v` -Expected: PASS - -**Step 6: Update existing TestAppNewThread test** - -The existing `TestAppNewThread` (line 351) and `TestAppHandlesThreadCreatedMsg` (line 363) need updating since behavior changed. `TestAppNewThread` should verify the cmd produces a `ThreadCreatedMsg`. `TestAppHandlesThreadCreatedMsg` now expects a pinned session — update or split the test. - -**Step 7: Run all tests** - -Run: `go test ./internal/tui -v` -Expected: All pass - -**Step 8: Commit** - -```bash -git add internal/tui/app.go internal/tui/app_test.go internal/tui/canvas.go -git commit -m "feat: n key spawns new session and auto-opens it" -``` - ---- - -### Task 5: Full-Height Layout — Test - -**Files:** -- Modify: `internal/tui/canvas_test.go` -- Modify: `internal/tui/card_test.go` - -**Step 1: Write failing tests for dynamic card sizing** - -Add to `card_test.go`: - -```go -func TestCardDynamicSize(t *testing.T) { - thread := state.NewThreadState("t-1", "Test") - thread.Status = state.StatusActive - - card := NewCardModel(thread, false) - card.SetSize(50, 10) - output := card.View() - - if !strings.Contains(output, "Test") { - t.Errorf("expected title in dynamic card, got:\n%s", output) - } -} -``` - -Add to `canvas_test.go`: - -```go -func TestCanvasViewWithDimensions(t *testing.T) { - store := state.NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") - store.Add("t-3", "Third") - - canvas := NewCanvasModel(store) - canvas.SetDimensions(120, 30) - output := canvas.View() - - if !strings.Contains(output, "First") { - t.Errorf("expected First in output:\n%s", output) - } -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `go test ./internal/tui -run "TestCardDynamic|TestCanvasViewWithDimensions" -v` -Expected: FAIL — `SetSize`/`SetDimensions` undefined - ---- - -### Task 6: Full-Height Layout — Card Scaling - -**Files:** -- Modify: `internal/tui/card.go` - -**Step 1: Make card sizes dynamic** - -Replace fixed `cardWidth`/`cardHeight` constants with fields on `CardModel`. Keep the constants as defaults/minimums. - -```go -const ( - minCardWidth = 20 - minCardHeight = 4 -) - -type CardModel struct { - thread *state.ThreadState - selected bool - width int - height int -} - -func NewCardModel(thread *state.ThreadState, selected bool) CardModel { - return CardModel{ - thread: thread, - selected: selected, - width: minCardWidth, - height: minCardHeight, - } -} - -func (card *CardModel) SetSize(width int, height int) { - if width < minCardWidth { - width = minCardWidth - } - if height < minCardHeight { - height = minCardHeight - } - card.width = width - card.height = height -} - -func (card CardModel) View() string { - statusColor, exists := statusColors[card.thread.Status] - if !exists { - statusColor = defaultStatusColor - } - - statusLine := lipgloss.NewStyle(). - Foreground(statusColor). - Render(card.thread.Status) - - title := truncate(card.thread.Title, card.width-4) - content := fmt.Sprintf("%s\n%s", title, statusLine) - - style := lipgloss.NewStyle(). - Width(card.width). - Height(card.height). - Border(lipgloss.RoundedBorder()). - Padding(0, 1) - - if card.selected { - style = style. - Border(lipgloss.DoubleBorder()). - BorderForeground(lipgloss.Color("39")) - } - - return style.Render(content) -} -``` - -Remove the old `cardStyle` and `selectedCardStyle` package-level vars. - -**Step 2: Run card tests** - -Run: `go test ./internal/tui -run TestCard -v` -Expected: PASS - ---- - -### Task 7: Full-Height Layout — Canvas Centering - -**Files:** -- Modify: `internal/tui/canvas.go` - -**Step 1: Add dimensions and centering to CanvasModel** - -```go -type CanvasModel struct { - store *state.ThreadStore - selected int - width int - height int -} - -func (canvas *CanvasModel) SetDimensions(width int, height int) { - canvas.width = width - canvas.height = height -} -``` - -**Step 2: Update View() to use dynamic sizing and centering** - -```go -func (canvas *CanvasModel) View() string { - threads := canvas.store.All() - if len(threads) == 0 { - return lipgloss.Place(canvas.width, canvas.height, - lipgloss.Center, lipgloss.Center, - "No active threads. Press 'n' to create one.") - } - - numRows := (len(threads) + canvasColumns - 1) / canvasColumns - cardWidth, cardHeight := canvas.cardDimensions(numRows) - - var rows []string - for rowStart := 0; rowStart < len(threads); rowStart += canvasColumns { - rowEnd := rowStart + canvasColumns - if rowEnd > len(threads) { - rowEnd = len(threads) - } - - var cards []string - for index := rowStart; index < rowEnd; index++ { - isSelected := index == canvas.selected - card := NewCardModel(threads[index], isSelected) - card.SetSize(cardWidth, cardHeight) - cards = append(cards, card.View()) - } - - rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, cards...)) - } - - grid := strings.Join(rows, "\n") - return lipgloss.Place(canvas.width, canvas.height, - lipgloss.Center, lipgloss.Center, grid) -} - -func (canvas CanvasModel) cardDimensions(numRows int) (int, int) { - columnGap := 0 - rowGap := 1 - - cardWidth := (canvas.width - columnGap*(canvasColumns-1)) / canvasColumns - if cardWidth < minCardWidth { - cardWidth = minCardWidth - } - - totalRowGaps := rowGap * (numRows - 1) - cardHeight := (canvas.height - totalRowGaps) / numRows - if cardHeight < minCardHeight { - cardHeight = minCardHeight - } - - return cardWidth, cardHeight -} -``` - -**Step 3: Run canvas tests** - -Run: `go test ./internal/tui -run TestCanvas -v` -Expected: PASS - ---- - -### Task 8: Full-Height Layout — View Composition - -**Files:** -- Modify: `internal/tui/app_view.go` - -**Step 1: Update View() for full-height layout** - -The key change: compute available canvas height, pass dimensions to canvas, use `lipgloss.JoinVertical` to stack header + canvas + status bar across the full terminal height. - -```go -const ( - headerHeight = 1 - statusBarHeight = 1 -) - -func (app AppModel) View() string { - header := app.header.View() - status := app.statusBar.View() - - if app.helpVisible { - return header + "\n" + app.help.View() + "\n" + status - } - - if app.menuVisible { - return header + "\n" + app.menu.View() + "\n" + status - } - - hasPinned := len(app.sessionPanel.PinnedSessions()) > 0 - - if hasPinned { - canvasHeight := int(float64(app.height) * app.sessionPanel.SplitRatio()) - headerHeight - statusBarHeight - if canvasHeight < 1 { - canvasHeight = 1 - } - app.canvas.SetDimensions(app.width, canvasHeight) - canvas := app.renderCanvas() - divider := app.renderDivider() - panel := app.renderSessionPanel() - return header + "\n" + canvas + "\n" + divider + "\n" + panel + "\n" + status - } - - canvasHeight := app.height - headerHeight - statusBarHeight - if canvasHeight < 1 { - canvasHeight = 1 - } - app.canvas.SetDimensions(app.width, canvasHeight) - canvas := app.renderCanvas() - return header + "\n" + canvas + "\n" + status -} -``` - -**Step 2: Run all tests** - -Run: `go test ./internal/tui -v` -Expected: All pass - -**Step 3: Run linter** - -Run: `golangci-lint run ./internal/tui/...` -Expected: No new issues (check funlen, file length) - -**Step 4: Commit** - -```bash -git add internal/tui/card.go internal/tui/card_test.go internal/tui/canvas.go internal/tui/canvas_test.go internal/tui/app_view.go -git commit -m "feat: full-height layout with centered, scaled cards" -``` - ---- - -### Task 9: Integration Test and Final Verification - -**Step 1: Build the binary** - -Run: `go build -o dj ./cmd/dj` -Expected: Builds cleanly - -**Step 2: Run full test suite with race detector** - -Run: `go test ./... -v -race` -Expected: All pass - -**Step 3: Run linter** - -Run: `golangci-lint run` -Expected: Clean - -**Step 4: Commit any fixups** - -If any fixes were needed, commit them.