diff --git a/docs/plans/2026-03-18-sub-agent-visualization-design.md b/docs/plans/2026-03-18-sub-agent-visualization-design.md new file mode 100644 index 0000000..886c013 --- /dev/null +++ b/docs/plans/2026-03-18-sub-agent-visualization-design.md @@ -0,0 +1,171 @@ +# 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 new file mode 100644 index 0000000..3036a99 --- /dev/null +++ b/docs/plans/2026-03-18-sub-agent-visualization-plan.md @@ -0,0 +1,1918 @@ +# 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/internal/appserver/client.go b/internal/appserver/client.go index b07f0e5..b15b99e 100644 --- a/internal/appserver/client.go +++ b/internal/appserver/client.go @@ -13,6 +13,8 @@ import ( const scannerBufferSize = 1024 * 1024 +const jsonRPCVersion = "2.0" + // Client manages a child codex proto process and bidirectional communication. type Client struct { command string @@ -28,7 +30,7 @@ type Client struct { nextID atomic.Int64 running atomic.Bool - OnEvent func(event ProtoEvent) + OnEvent func(message JSONRPCMessage) OnStderr func(line string) } @@ -41,137 +43,99 @@ func NewClient(command string, args ...string) *Client { } // Start spawns the child process. -func (c *Client) Start(ctx context.Context) error { - c.cmd = exec.CommandContext(ctx, c.command, c.args...) +func (client *Client) Start(ctx context.Context) error { + client.cmd = exec.CommandContext(ctx, client.command, client.args...) var err error - c.stdin, err = c.cmd.StdinPipe() + if err = client.setupPipes(); err != nil { + return err + } + + client.scanner = bufio.NewScanner(client.stdout) + client.scanner.Buffer(make([]byte, scannerBufferSize), scannerBufferSize) + + if err = client.cmd.Start(); err != nil { + return fmt.Errorf("start process: %w", err) + } + + client.running.Store(true) + go client.drainStderr() + return nil +} + +func (client *Client) setupPipes() error { + var err error + client.stdin, err = client.cmd.StdinPipe() if err != nil { return fmt.Errorf("stdin pipe: %w", err) } - c.stdout, err = c.cmd.StdoutPipe() + client.stdout, err = client.cmd.StdoutPipe() if err != nil { return fmt.Errorf("stdout pipe: %w", err) } - c.stderr, err = c.cmd.StderrPipe() + client.stderr, err = client.cmd.StderrPipe() if err != nil { return fmt.Errorf("stderr pipe: %w", err) } - - c.scanner = bufio.NewScanner(c.stdout) - c.scanner.Buffer(make([]byte, scannerBufferSize), scannerBufferSize) - - if err := c.cmd.Start(); err != nil { - return fmt.Errorf("start process: %w", err) - } - - c.running.Store(true) - go c.drainStderr() return nil } // Running returns true if the child process is alive. -func (c *Client) Running() bool { - return c.running.Load() +func (client *Client) Running() bool { + return client.running.Load() } // Stop terminates the child process gracefully. -func (c *Client) Stop() error { - if !c.running.Load() { +func (client *Client) Stop() error { + if !client.running.Load() { return nil } - c.running.Store(false) + client.running.Store(false) - if c.stdin != nil { - c.stdin.Close() + if client.stdin != nil { + client.stdin.Close() } - if c.cmd != nil && c.cmd.Process != nil { - return c.cmd.Wait() + hasProcess := client.cmd != nil && client.cmd.Process != nil + if hasProcess { + return client.cmd.Wait() } return nil } -func (c *Client) drainStderr() { - if c.stderr == nil { +func (client *Client) drainStderr() { + if client.stderr == nil { return } - scanner := bufio.NewScanner(c.stderr) + scanner := bufio.NewScanner(client.stderr) scanner.Buffer(make([]byte, scannerBufferSize), scannerBufferSize) for scanner.Scan() { - if c.OnStderr != nil { - c.OnStderr(scanner.Text()) + if client.OnStderr != nil { + client.OnStderr(scanner.Text()) } } } -// Send writes a ProtoSubmission to the child's stdin as a JSONL line. -func (c *Client) Send(sub *ProtoSubmission) error { - data, err := json.Marshal(sub) - if err != nil { - return fmt.Errorf("marshal submission: %w", err) - } - - c.mu.Lock() - defer c.mu.Unlock() - - data = append(data, '\n') - _, err = c.stdin.Write(data) - return err -} - // NextID returns a unique string ID for submissions. -func (c *Client) NextID() string { - return fmt.Sprintf("dj-%d", c.nextID.Add(1)) +func (client *Client) NextID() string { + return fmt.Sprintf("dj-%d", client.nextID.Add(1)) } -// ReadLoop reads JSONL events from stdout and dispatches each to the handler. -func (c *Client) ReadLoop(handler func(ProtoEvent)) { - for c.scanner.Scan() { - line := c.scanner.Bytes() +// ReadLoop reads JSONL messages from stdout and dispatches each to the handler. +func (client *Client) ReadLoop(handler func(JSONRPCMessage)) { + for client.scanner.Scan() { + line := client.scanner.Bytes() if len(line) == 0 { continue } - var event ProtoEvent - if err := json.Unmarshal(line, &event); err != nil { + var message JSONRPCMessage + if err := json.Unmarshal(line, &message); err != nil { continue } - handler(event) + handler(message) } } - -// SendUserInput sends a text message to the codex session. -func (c *Client) SendUserInput(text string) (string, error) { - id := c.NextID() - op := UserInputOp{ - Type: OpUserInput, - Items: []InputItem{ - {Type: "text", Text: text}, - }, - } - opData, _ := json.Marshal(op) - sub := &ProtoSubmission{ - ID: id, - Op: opData, - } - return id, c.Send(sub) -} - -// SendInterrupt sends an interrupt to cancel the current task. -func (c *Client) SendInterrupt() error { - id := c.NextID() - op := map[string]string{"type": OpInterrupt} - opData, _ := json.Marshal(op) - return c.Send(&ProtoSubmission{ID: id, Op: opData}) -} - -// SendApproval responds to an exec or patch approval request. -func (c *Client) SendApproval(eventID string, opType string, approved bool) error { - op := map[string]any{"type": opType, "approved": approved} - opData, _ := json.Marshal(op) - return c.Send(&ProtoSubmission{ID: eventID, Op: opData}) -} diff --git a/internal/appserver/client_send.go b/internal/appserver/client_send.go new file mode 100644 index 0000000..08c2599 --- /dev/null +++ b/internal/appserver/client_send.go @@ -0,0 +1,60 @@ +package appserver + +import ( + "encoding/json" + "fmt" +) + +// Send writes a JSON-RPC request to the child's stdin as a JSONL line. +func (client *Client) Send(request *JSONRPCRequest) error { + return client.writeJSON(request) +} + +// SendResponse writes a JSON-RPC response to the child's stdin as a JSONL line. +func (client *Client) SendResponse(response *JSONRPCResponse) error { + return client.writeJSON(response) +} + +func (client *Client) writeJSON(payload interface{}) error { + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + client.mu.Lock() + defer client.mu.Unlock() + + data = append(data, '\n') + _, err = client.stdin.Write(data) + return err +} + +// SendUserInput sends a text message via turn/start. +func (client *Client) SendUserInput(text string) (string, error) { + requestID := client.NextID() + request := &JSONRPCRequest{ + jsonRPCOutgoing: jsonRPCOutgoing{JSONRPC: jsonRPCVersion, ID: requestID}, + Method: MethodTurnStart, + Params: map[string]string{"message": text}, + } + return requestID, client.Send(request) +} + +// SendInterrupt sends a turn/interrupt request. +func (client *Client) SendInterrupt() error { + requestID := client.NextID() + request := &JSONRPCRequest{ + jsonRPCOutgoing: jsonRPCOutgoing{JSONRPC: jsonRPCVersion, ID: requestID}, + Method: MethodTurnInterrupt, + } + return client.Send(request) +} + +// SendApproval responds to a server approval request. +func (client *Client) SendApproval(requestID string, approved bool) error { + response := &JSONRPCResponse{ + jsonRPCOutgoing: jsonRPCOutgoing{JSONRPC: jsonRPCVersion, ID: requestID}, + Result: map[string]bool{"approved": approved}, + } + return client.SendResponse(response) +} diff --git a/internal/appserver/client_test.go b/internal/appserver/client_test.go index 07d6525..6833c0c 100644 --- a/internal/appserver/client_test.go +++ b/internal/appserver/client_test.go @@ -3,119 +3,164 @@ package appserver import ( "bufio" "context" - "encoding/json" "io" "testing" "time" ) -func TestClientStartStop(t *testing.T) { - client := NewClient("cat") +const ( + clientTestTimeout = 5 * time.Second + clientTestEventWait = 3 * time.Second + clientTestChannelSize = 10 + clientTestCommand = "cat" + clientTestStartFail = "Start failed: %v" + clientTestTimeoutMsg = "timeout waiting for message" + clientTestRequestID = "req-1" + clientTestSendID = "test-1" + clientTestNewline = "\n" + clientTestExpectedID = "expected id %s, got %s" + clientTestExpectedValue = "expected %s, got %s" +) + +func TestClientStartStop(test *testing.T) { + client := NewClient(clientTestCommand) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), clientTestTimeout) defer cancel() if err := client.Start(ctx); err != nil { - t.Fatalf("Start failed: %v", err) + test.Fatalf(clientTestStartFail, err) } if !client.Running() { - t.Fatal("expected client to be running") + test.Fatal("expected client to be running") } if err := client.Stop(); err != nil { - t.Fatalf("Stop failed: %v", err) + test.Fatalf("Stop failed: %v", err) } if client.Running() { - t.Fatal("expected client to be stopped") + test.Fatal("expected client to be stopped") } } -func TestClientSendAndReadLoop(t *testing.T) { - client := NewClient("cat") +func TestClientSendAndReadLoop(test *testing.T) { + client := NewClient(clientTestCommand) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), clientTestTimeout) defer cancel() if err := client.Start(ctx); err != nil { - t.Fatalf("Start failed: %v", err) + test.Fatalf(clientTestStartFail, err) } defer client.Stop() - events := make(chan ProtoEvent, 10) - go client.ReadLoop(func(event ProtoEvent) { - events <- event + messages := make(chan JSONRPCMessage, clientTestChannelSize) + go client.ReadLoop(func(message JSONRPCMessage) { + messages <- message }) - sub := &ProtoSubmission{ - ID: "test-1", - Op: json.RawMessage(`{"type":"user_input"}`), + request := &JSONRPCRequest{ + jsonRPCOutgoing: jsonRPCOutgoing{JSONRPC: jsonRPCVersion, ID: clientTestSendID}, + Method: MethodTurnStart, } - if err := client.Send(sub); err != nil { - t.Fatalf("Send failed: %v", err) + if err := client.Send(request); err != nil { + test.Fatalf("Send failed: %v", err) } select { - case event := <-events: - if event.ID != "test-1" { - t.Errorf("expected id test-1, got %s", event.ID) + case message := <-messages: + if message.ID != clientTestSendID { + test.Errorf(clientTestExpectedID, clientTestSendID, message.ID) } - case <-time.After(3 * time.Second): - t.Fatal("timeout waiting for event") + case <-time.After(clientTestEventWait): + test.Fatal(clientTestTimeoutMsg) } } -func TestClientNextID(t *testing.T) { +func TestClientNextID(test *testing.T) { client := NewClient("echo") first := client.NextID() second := client.NextID() if first == second { - t.Error("expected unique IDs") + test.Error("expected unique IDs") } if first != "dj-1" { - t.Errorf("expected dj-1, got %s", first) + test.Errorf("expected dj-1, got %s", first) } if second != "dj-2" { - t.Errorf("expected dj-2, got %s", second) + test.Errorf("expected dj-2, got %s", second) } } -func TestClientSendUserInput(t *testing.T) { - client := NewClient("cat") +func TestClientSendUserInput(test *testing.T) { + client := NewClient(clientTestCommand) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), clientTestTimeout) defer cancel() if err := client.Start(ctx); err != nil { - t.Fatalf("Start failed: %v", err) + test.Fatalf(clientTestStartFail, err) } defer client.Stop() - events := make(chan ProtoEvent, 10) - go client.ReadLoop(func(event ProtoEvent) { - events <- event + messages := make(chan JSONRPCMessage, clientTestChannelSize) + go client.ReadLoop(func(message JSONRPCMessage) { + messages <- message }) - id, err := client.SendUserInput("Hello") + requestID, err := client.SendUserInput("Hello") if err != nil { - t.Fatalf("SendUserInput failed: %v", err) + test.Fatalf("SendUserInput failed: %v", err) } - if id == "" { - t.Error("expected non-empty id") + if requestID == "" { + test.Error("expected non-empty id") } select { - case event := <-events: - if event.ID != id { - t.Errorf("expected id %s, got %s", id, event.ID) + case message := <-messages: + if message.ID != requestID { + test.Errorf(clientTestExpectedID, requestID, message.ID) + } + if message.Method != MethodTurnStart { + test.Errorf("expected method %s, got %s", MethodTurnStart, message.Method) + } + case <-time.After(clientTestEventWait): + test.Fatal(clientTestTimeoutMsg) + } +} + +func TestReadLoopParsesV2Notification(test *testing.T) { + clientRead, serverWrite := io.Pipe() + + client := &Client{} + client.stdout = clientRead + client.scanner = bufio.NewScanner(clientRead) + client.scanner.Buffer(make([]byte, scannerBufferSize), scannerBufferSize) + client.running.Store(true) + + messages := make(chan JSONRPCMessage, clientTestChannelSize) + go client.ReadLoop(func(message JSONRPCMessage) { + messages <- message + }) + + line := `{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"t-1"}}}` + clientTestNewline + serverWrite.Write([]byte(line)) + + select { + case message := <-messages: + if message.Method != MethodThreadStarted { + test.Errorf(clientTestExpectedValue, MethodThreadStarted, message.Method) } - case <-time.After(3 * time.Second): - t.Fatal("timeout waiting for event") + case <-time.After(clientTestEventWait): + test.Fatal(clientTestTimeoutMsg) } + + serverWrite.Close() } -func TestReadLoopParsesProtoEvents(t *testing.T) { +func TestReadLoopParsesV2Request(test *testing.T) { clientRead, serverWrite := io.Pipe() client := &Client{} @@ -124,55 +169,56 @@ func TestReadLoopParsesProtoEvents(t *testing.T) { client.scanner.Buffer(make([]byte, scannerBufferSize), scannerBufferSize) client.running.Store(true) - events := make(chan ProtoEvent, 10) - go client.ReadLoop(func(event ProtoEvent) { - events <- event + messages := make(chan JSONRPCMessage, clientTestChannelSize) + go client.ReadLoop(func(message JSONRPCMessage) { + messages <- message }) - eventJSON := `{"id":"","msg":{"type":"session_configured","session_id":"s-1","model":"o4-mini"}}` + "\n" - serverWrite.Write([]byte(eventJSON)) + line := `{"jsonrpc":"2.0","id":"req-1","method":"item/commandExecution/requestApproval","params":{"command":"ls"}}` + clientTestNewline + serverWrite.Write([]byte(line)) select { - case event := <-events: - var header EventHeader - json.Unmarshal(event.Msg, &header) - if header.Type != EventSessionConfigured { - t.Errorf("expected session_configured, got %s", header.Type) + case message := <-messages: + if message.ID != clientTestRequestID { + test.Errorf(clientTestExpectedValue, clientTestRequestID, message.ID) + } + if !message.IsRequest() { + test.Error("should be a request") } - case <-time.After(3 * time.Second): - t.Fatal("timeout waiting for event") + case <-time.After(clientTestEventWait): + test.Fatal(clientTestTimeoutMsg) } serverWrite.Close() } -func TestClientSendApproval(t *testing.T) { - client := NewClient("cat") +func TestClientSendApproval(test *testing.T) { + client := NewClient(clientTestCommand) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), clientTestTimeout) defer cancel() if err := client.Start(ctx); err != nil { - t.Fatalf("Start failed: %v", err) + test.Fatalf(clientTestStartFail, err) } defer client.Stop() - events := make(chan ProtoEvent, 10) - go client.ReadLoop(func(event ProtoEvent) { - events <- event + messages := make(chan JSONRPCMessage, clientTestChannelSize) + go client.ReadLoop(func(message JSONRPCMessage) { + messages <- message }) - err := client.SendApproval("req-1", OpExecApproval, true) + err := client.SendApproval(clientTestRequestID, true) if err != nil { - t.Fatalf("SendApproval failed: %v", err) + test.Fatalf("SendApproval failed: %v", err) } select { - case event := <-events: - if event.ID != "req-1" { - t.Errorf("expected id req-1, got %s", event.ID) + case message := <-messages: + if message.ID != clientTestRequestID { + test.Errorf(clientTestExpectedID, clientTestRequestID, message.ID) } - case <-time.After(3 * time.Second): - t.Fatal("timeout waiting for event") + case <-time.After(clientTestEventWait): + test.Fatal(clientTestTimeoutMsg) } } diff --git a/internal/appserver/integration_test.go b/internal/appserver/integration_test.go index 6e57d3b..8f58a11 100644 --- a/internal/appserver/integration_test.go +++ b/internal/appserver/integration_test.go @@ -4,38 +4,36 @@ package appserver import ( "context" - "encoding/json" "testing" "time" ) -func TestIntegrationProtoConnect(t *testing.T) { +const integrationTestTimeout = 15 * time.Second +const integrationEventBuffer = 10 + +func TestIntegrationV2Connect(test *testing.T) { client := NewClient("codex", "proto") - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), integrationTestTimeout) defer cancel() if err := client.Start(ctx); err != nil { - t.Fatalf("Failed to start codex proto: %v", err) + test.Fatalf("Failed to start codex proto: %v", err) } defer client.Stop() - events := make(chan ProtoEvent, 10) - go client.ReadLoop(func(event ProtoEvent) { - events <- event + events := make(chan JSONRPCMessage, integrationEventBuffer) + go client.ReadLoop(func(message JSONRPCMessage) { + events <- message }) select { - case event := <-events: - var header EventHeader - if err := json.Unmarshal(event.Msg, &header); err != nil { - t.Fatalf("unmarshal header: %v", err) - } - if header.Type != EventSessionConfigured { - t.Errorf("expected session_configured, got %s", header.Type) + case message := <-events: + if message.Method == "" { + test.Fatal("expected a notification with a method") } - t.Logf("Connected: received %s event", header.Type) + test.Logf("Connected: received method %s", message.Method) case <-ctx.Done(): - t.Fatal("timeout waiting for session_configured") + test.Fatal("timeout waiting for first event") } } diff --git a/internal/appserver/methods.go b/internal/appserver/methods.go deleted file mode 100644 index 8330caf..0000000 --- a/internal/appserver/methods.go +++ /dev/null @@ -1,23 +0,0 @@ -package appserver - -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" -) - -const ( - OpUserInput = "user_input" - OpInterrupt = "interrupt" - OpExecApproval = "exec_approval" - OpPatchApproval = "patch_approval" - OpShutdown = "shutdown" -) diff --git a/internal/appserver/methods_test.go b/internal/appserver/methods_test.go deleted file mode 100644 index cae684f..0000000 --- a/internal/appserver/methods_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package appserver - -import "testing" - -func TestEventConstants(t *testing.T) { - tests := []struct { - name string - constant string - expected string - }{ - {"SessionConfigured", EventSessionConfigured, "session_configured"}, - {"TaskStarted", EventTaskStarted, "task_started"}, - {"TaskComplete", EventTaskComplete, "task_complete"}, - {"AgentMessage", EventAgentMessage, "agent_message"}, - {"AgentMessageDelta", EventAgentMessageDelta, "agent_message_delta"}, - {"AgentReasoning", EventAgentReasoning, "agent_reasoning"}, - {"AgentReasonDelta", EventAgentReasonDelta, "agent_reasoning_delta"}, - {"TokenCount", EventTokenCount, "token_count"}, - {"ExecApproval", EventExecApproval, "exec_command_request"}, - {"PatchApproval", EventPatchApproval, "patch_apply_request"}, - {"ReasonBreak", EventAgentReasonBreak, "agent_reasoning_section_break"}, - } - 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) - } - }) - } -} - -func TestOpConstants(t *testing.T) { - tests := []struct { - name string - constant string - expected string - }{ - {"UserInput", OpUserInput, "user_input"}, - {"Interrupt", OpInterrupt, "interrupt"}, - {"ExecApproval", OpExecApproval, "exec_approval"}, - {"PatchApproval", OpPatchApproval, "patch_approval"}, - {"Shutdown", OpShutdown, "shutdown"}, - } - 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) - } - }) - } -} diff --git a/internal/appserver/methods_v2.go b/internal/appserver/methods_v2.go new file mode 100644 index 0000000..0584f16 --- /dev/null +++ b/internal/appserver/methods_v2.go @@ -0,0 +1,44 @@ +package appserver + +// 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" +) + +// V2 collaboration notification methods. +const ( + MethodCollabSpawnBegin = "collab/agentSpawn/begin" + MethodCollabSpawnEnd = "collab/agentSpawn/end" + MethodCollabInteractionBegin = "collab/agentInteraction/begin" + MethodCollabInteractionEnd = "collab/agentInteraction/end" + MethodCollabWaitingBegin = "collab/agentWaiting/begin" + MethodCollabWaitingEnd = "collab/agentWaiting/end" + MethodCollabCloseBegin = "collab/agentClose/begin" + MethodCollabCloseEnd = "collab/agentClose/end" + MethodCollabResumeBegin = "collab/agentResume/begin" + MethodCollabResumeEnd = "collab/agentResume/end" +) diff --git a/internal/appserver/protocol.go b/internal/appserver/protocol.go index f4f3ee0..8650546 100644 --- a/internal/appserver/protocol.go +++ b/internal/appserver/protocol.go @@ -2,21 +2,28 @@ package appserver import "encoding/json" -// ProtoEvent is an incoming event from the codex proto stream. -// Format: {"id":"","msg":{"type":"",...}} -type ProtoEvent struct { - ID string `json:"id"` - Msg json.RawMessage `json:"msg"` +// 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"` } -// EventHeader extracts just the type field from a ProtoEvent.Msg payload. -type EventHeader struct { - Type string `json:"type"` +// IsRequest returns true if this message is a server-to-client request. +func (message JSONRPCMessage) IsRequest() bool { + return message.ID != "" && message.Method != "" } -// ProtoSubmission is an outgoing operation sent to codex proto. -// Format: {"id":"","op":{"type":"",...}} -type ProtoSubmission struct { - ID string `json:"id"` - Op json.RawMessage `json:"op"` +// 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 != "" +} + diff --git a/internal/appserver/protocol_outgoing.go b/internal/appserver/protocol_outgoing.go new file mode 100644 index 0000000..cbbff01 --- /dev/null +++ b/internal/appserver/protocol_outgoing.go @@ -0,0 +1,19 @@ +package appserver + +type jsonRPCOutgoing struct { + JSONRPC string `json:"jsonrpc"` + ID string `json:"id"` +} + +// JSONRPCRequest is an outgoing client-to-server request. +type JSONRPCRequest struct { + jsonRPCOutgoing + Method string `json:"method"` + Params interface{} `json:"params,omitempty"` +} + +// JSONRPCResponse is an outgoing client response to a server request. +type JSONRPCResponse struct { + jsonRPCOutgoing + Result interface{} `json:"result"` +} diff --git a/internal/appserver/protocol_test.go b/internal/appserver/protocol_test.go index cae0839..9acf821 100644 --- a/internal/appserver/protocol_test.go +++ b/internal/appserver/protocol_test.go @@ -5,49 +5,43 @@ import ( "testing" ) -func TestProtoEventUnmarshal(t *testing.T) { - raw := `{"id":"","msg":{"type":"session_configured","session_id":"s1","model":"o4-mini"}}` - var event ProtoEvent - if err := json.Unmarshal([]byte(raw), &event); err != nil { - t.Fatal(err) - } - if event.ID != "" { - t.Errorf("expected empty id, got %s", event.ID) - } +const unmarshalFailFormat = "unmarshal: %v" - var header EventHeader - if err := json.Unmarshal(event.Msg, &header); err != nil { - t.Fatal(err) +func TestParseNotification(test *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 { + test.Fatalf(unmarshalFailFormat, err) } - if header.Type != "session_configured" { - t.Errorf("expected session_configured, got %s", header.Type) + if message.Method != "thread/started" { + test.Errorf("expected thread/started, got %s", message.Method) + } + if message.IsRequest() { + test.Error("notification should not be a request") } } -func TestProtoEventWithID(t *testing.T) { - raw := `{"id":"req-1","msg":{"type":"exec_command_request","command":"ls"}}` - var event ProtoEvent - if err := json.Unmarshal([]byte(raw), &event); err != nil { - t.Fatal(err) +func TestParseRequest(test *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 { + test.Fatalf(unmarshalFailFormat, err) + } + if message.ID != "req-1" { + test.Errorf("expected req-1, got %s", message.ID) } - if event.ID != "req-1" { - t.Errorf("expected req-1, got %s", event.ID) + if !message.IsRequest() { + test.Error("should be a request") } } -func TestProtoSubmissionMarshal(t *testing.T) { - op, _ := json.Marshal(map[string]string{"type": "user_input"}) - sub := &ProtoSubmission{ - ID: "dj-1", - Op: op, - } - data, err := json.Marshal(sub) - if err != nil { - t.Fatal(err) - } - var parsed map[string]any - json.Unmarshal(data, &parsed) - if parsed["id"] != "dj-1" { - t.Errorf("expected dj-1, got %v", parsed["id"]) +func TestParseResponse(test *testing.T) { + raw := `{"jsonrpc":"2.0","id":"dj-1","result":{"ok":true}}` + var message JSONRPCMessage + if err := json.Unmarshal([]byte(raw), &message); err != nil { + test.Fatalf(unmarshalFailFormat, err) + } + if !message.IsResponse() { + test.Error("should be a response") } } diff --git a/internal/appserver/types_approval_v2.go b/internal/appserver/types_approval_v2.go new file mode 100644 index 0000000..4e5c5ac --- /dev/null +++ b/internal/appserver/types_approval_v2.go @@ -0,0 +1,13 @@ +package appserver + +// CommandApprovalRequest is the v2 params payload for command execution approval. +type CommandApprovalRequest struct { + threadScoped + Command CommandDetails `json:"command"` +} + +// FileChangeApprovalRequest is the v2 params payload for file change approval. +type FileChangeApprovalRequest struct { + threadScoped + Patch string `json:"patch"` +} diff --git a/internal/appserver/types_approval_v2_test.go b/internal/appserver/types_approval_v2_test.go new file mode 100644 index 0000000..ca7ee93 --- /dev/null +++ b/internal/appserver/types_approval_v2_test.go @@ -0,0 +1,20 @@ +package appserver + +import ( + "encoding/json" + "testing" +) + +func TestUnmarshalCommandApproval(test *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 { + test.Fatalf("approval unmarshal: %v", err) + } + if request.ThreadID != "t-1" { + test.Errorf("expected t-1, got %s", request.ThreadID) + } + if request.Command.Command != "ls -la" { + test.Errorf("expected ls -la, got %s", request.Command.Command) + } +} diff --git a/internal/appserver/types_collab.go b/internal/appserver/types_collab.go new file mode 100644 index 0000000..38b496e --- /dev/null +++ b/internal/appserver/types_collab.go @@ -0,0 +1,31 @@ +package appserver + +const ( + AgentStatusPendingInit = "pending_init" + AgentStatusRunning = "running" + AgentStatusInterrupted = "interrupted" + AgentStatusCompleted = "completed" + AgentStatusErrored = "errored" + AgentStatusShutdown = "shutdown" +) + +type collabBase struct { + CallID string `json:"call_id"` + SenderThreadID string `json:"sender_thread_id"` +} + +// CollabSpawnBeginEvent is the params payload for collab/agentSpawn/begin. +type CollabSpawnBeginEvent struct { + collabBase + Prompt string `json:"prompt,omitempty"` + Model string `json:"model,omitempty"` +} + +// CollabSpawnEndEvent is the params payload for collab/agentSpawn/end. +type CollabSpawnEndEvent struct { + collabBase + NewThreadID string `json:"new_thread_id"` + NewAgentNickname string `json:"new_agent_nickname,omitempty"` + NewAgentRole string `json:"new_agent_role,omitempty"` + Status string `json:"status"` +} diff --git a/internal/appserver/types_collab_interaction.go b/internal/appserver/types_collab_interaction.go new file mode 100644 index 0000000..a5c774c --- /dev/null +++ b/internal/appserver/types_collab_interaction.go @@ -0,0 +1,20 @@ +package appserver + +type collabInteractionBase struct { + collabBase + ReceiverThreadID string `json:"receiver_thread_id"` +} + +// CollabInteractionBeginEvent is the params payload for collab/agentInteraction/begin. +type CollabInteractionBeginEvent struct { + collabInteractionBase + Prompt string `json:"prompt,omitempty"` +} + +// CollabInteractionEndEvent is the params payload for collab/agentInteraction/end. +type CollabInteractionEndEvent struct { + collabInteractionBase + ReceiverAgentNickname string `json:"receiver_agent_nickname,omitempty"` + ReceiverAgentRole string `json:"receiver_agent_role,omitempty"` + Status string `json:"status"` +} diff --git a/internal/appserver/types_collab_lifecycle.go b/internal/appserver/types_collab_lifecycle.go new file mode 100644 index 0000000..820fd44 --- /dev/null +++ b/internal/appserver/types_collab_lifecycle.go @@ -0,0 +1,18 @@ +package appserver + +type collabReceiverBase struct { + collabBase + ReceiverThreadID string `json:"receiver_thread_id"` + ReceiverAgentNickname string `json:"receiver_agent_nickname,omitempty"` + ReceiverAgentRole string `json:"receiver_agent_role,omitempty"` +} + +// CollabCloseBeginEvent is the params payload for collab/agentClose/begin. +type CollabCloseBeginEvent struct { + collabReceiverBase +} + +// CollabResumeBeginEvent is the params payload for collab/agentResume/begin. +type CollabResumeBeginEvent struct { + collabReceiverBase +} diff --git a/internal/appserver/types_collab_lifecycle_end.go b/internal/appserver/types_collab_lifecycle_end.go new file mode 100644 index 0000000..cd4379f --- /dev/null +++ b/internal/appserver/types_collab_lifecycle_end.go @@ -0,0 +1,16 @@ +package appserver + +type collabReceiverEndEvent struct { + collabReceiverBase + Status string `json:"status"` +} + +// CollabCloseEndEvent is the params payload for collab/agentClose/end. +type CollabCloseEndEvent struct { + collabReceiverEndEvent +} + +// CollabResumeEndEvent is the params payload for collab/agentResume/end. +type CollabResumeEndEvent struct { + collabReceiverEndEvent +} diff --git a/internal/appserver/types_collab_test.go b/internal/appserver/types_collab_test.go new file mode 100644 index 0000000..903724c --- /dev/null +++ b/internal/appserver/types_collab_test.go @@ -0,0 +1,73 @@ +package appserver + +import ( + "encoding/json" + "testing" +) + +const ( + collabUnmarshalFailFormat = "collab unmarshal: %v" + collabChildThreadID = "t-1" + collabExpectedChildFormat = "expected t-1, got %s" + expectedWaitingStatuses = 2 +) + +func TestUnmarshalCollabSpawnEnd(test *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 { + test.Fatalf(collabUnmarshalFailFormat, err) + } + if event.SenderThreadID != "t-0" { + test.Errorf("expected t-0, got %s", event.SenderThreadID) + } + if event.NewThreadID != collabChildThreadID { + test.Errorf(collabExpectedChildFormat, event.NewThreadID) + } + if event.Status != AgentStatusRunning { + test.Errorf("expected running, got %s", event.Status) + } +} + +func TestUnmarshalCollabWaitingEnd(test *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 { + test.Fatalf(collabUnmarshalFailFormat, err) + } + if len(event.Statuses) != expectedWaitingStatuses { + test.Errorf("expected %d statuses, got %d", expectedWaitingStatuses, len(event.Statuses)) + } +} + +func TestUnmarshalCollabCloseEnd(test *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 { + test.Fatalf(collabUnmarshalFailFormat, err) + } + if event.ReceiverThreadID != collabChildThreadID { + test.Errorf(collabExpectedChildFormat, event.ReceiverThreadID) + } + if event.Status != AgentStatusShutdown { + test.Errorf("expected shutdown, got %s", event.Status) + } +} diff --git a/internal/appserver/types_collab_waiting.go b/internal/appserver/types_collab_waiting.go new file mode 100644 index 0000000..d7ce93b --- /dev/null +++ b/internal/appserver/types_collab_waiting.go @@ -0,0 +1,13 @@ +package appserver + +// CollabWaitingBeginEvent is the params payload for collab/agentWaiting/begin. +type CollabWaitingBeginEvent struct { + collabBase + ReceiverThreadIDs []string `json:"receiver_thread_ids"` +} + +// CollabWaitingEndEvent is the params payload for collab/agentWaiting/end. +type CollabWaitingEndEvent struct { + collabBase + Statuses map[string]string `json:"statuses"` +} diff --git a/internal/appserver/types_command_details.go b/internal/appserver/types_command_details.go new file mode 100644 index 0000000..f43195c --- /dev/null +++ b/internal/appserver/types_command_details.go @@ -0,0 +1,7 @@ +package appserver + +// CommandDetails holds the command and working directory for approval requests. +type CommandDetails struct { + Command string `json:"command"` + Cwd string `json:"cwd,omitempty"` +} diff --git a/internal/appserver/types_notification_v2.go b/internal/appserver/types_notification_v2.go new file mode 100644 index 0000000..9f0feb5 --- /dev/null +++ b/internal/appserver/types_notification_v2.go @@ -0,0 +1,25 @@ +package appserver + +// ThreadStatusChangedNotification is the params payload for thread/status/changed. +type ThreadStatusChangedNotification struct { + threadScoped + Status string `json:"status"` +} + +// AgentMessageDeltaNotification is the params payload for item/agentMessage/delta. +type AgentMessageDeltaNotification struct { + threadScoped + Delta string `json:"delta"` +} + +// Item represents a v2 item object within notifications. +type Item struct { + ID string `json:"id"` + Type string `json:"type"` +} + +// ItemCompletedNotification is the params payload for item/completed. +type ItemCompletedNotification struct { + threadScoped + Item Item `json:"item"` +} diff --git a/internal/appserver/types_thread.go b/internal/appserver/types_thread.go deleted file mode 100644 index 2028cef..0000000 --- a/internal/appserver/types_thread.go +++ /dev/null @@ -1,44 +0,0 @@ -package appserver - -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"` -} - -type UserInputOp struct { - Type string `json:"type"` - Items []InputItem `json:"items"` -} - -type InputItem struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` -} - -type ExecCommandRequest struct { - Command string `json:"command"` - Cwd string `json:"cwd,omitempty"` -} - -type PatchApplyRequest struct { - Patch string `json:"patch"` -} diff --git a/internal/appserver/types_thread_test.go b/internal/appserver/types_thread_test.go deleted file mode 100644 index 5e2dedd..0000000 --- a/internal/appserver/types_thread_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package appserver - -import ( - "encoding/json" - "testing" -) - -func TestSessionConfiguredUnmarshal(t *testing.T) { - raw := `{"type":"session_configured","session_id":"s-123","model":"o4-mini","reasoning_effort":"medium","history_log_id":0}` - var cfg SessionConfigured - if err := json.Unmarshal([]byte(raw), &cfg); err != nil { - t.Fatal(err) - } - if cfg.SessionID != "s-123" { - t.Errorf("expected s-123, got %s", cfg.SessionID) - } - if cfg.Model != "o4-mini" { - t.Errorf("expected o4-mini, got %s", cfg.Model) - } -} - -func TestTaskStartedUnmarshal(t *testing.T) { - raw := `{"type":"task_started","model_context_window":200000}` - var started TaskStarted - if err := json.Unmarshal([]byte(raw), &started); err != nil { - t.Fatal(err) - } - if started.ModelContextWindow != 200000 { - t.Errorf("expected 200000, got %d", started.ModelContextWindow) - } -} - -func TestTaskCompleteUnmarshal(t *testing.T) { - raw := `{"type":"task_complete","last_agent_message":"Hello"}` - var complete TaskComplete - if err := json.Unmarshal([]byte(raw), &complete); err != nil { - t.Fatal(err) - } - if complete.LastAgentMessage != "Hello" { - t.Errorf("expected Hello, got %s", complete.LastAgentMessage) - } -} - -func TestAgentDeltaUnmarshal(t *testing.T) { - raw := `{"type":"agent_message_delta","delta":"Howdy"}` - var delta AgentDelta - if err := json.Unmarshal([]byte(raw), &delta); err != nil { - t.Fatal(err) - } - if delta.Delta != "Howdy" { - t.Errorf("expected Howdy, got %s", delta.Delta) - } -} - -func TestAgentMessageUnmarshal(t *testing.T) { - raw := `{"type":"agent_message","message":"Hello world"}` - var msg AgentMessage - if err := json.Unmarshal([]byte(raw), &msg); err != nil { - t.Fatal(err) - } - if msg.Message != "Hello world" { - t.Errorf("expected Hello world, got %s", msg.Message) - } -} - -func TestUserInputOpMarshal(t *testing.T) { - op := UserInputOp{ - Type: OpUserInput, - Items: []InputItem{ - {Type: "text", Text: "Say hello"}, - }, - } - data, err := json.Marshal(op) - if err != nil { - t.Fatal(err) - } - var parsed map[string]any - json.Unmarshal(data, &parsed) - if parsed["type"] != "user_input" { - t.Errorf("expected user_input, got %v", parsed["type"]) - } -} - -func TestExecCommandRequestUnmarshal(t *testing.T) { - raw := `{"type":"exec_command_request","command":"ls -la","cwd":"/tmp"}` - var req ExecCommandRequest - if err := json.Unmarshal([]byte(raw), &req); err != nil { - t.Fatal(err) - } - if req.Command != "ls -la" { - t.Errorf("expected ls -la, got %s", req.Command) - } - if req.Cwd != "/tmp" { - t.Errorf("expected /tmp, got %s", req.Cwd) - } -} diff --git a/internal/appserver/types_thread_v2.go b/internal/appserver/types_thread_v2.go new file mode 100644 index 0000000..956f4cd --- /dev/null +++ b/internal/appserver/types_thread_v2.go @@ -0,0 +1,33 @@ +package appserver + +const ( + SourceTypeCLI = "cli" + SourceTypeSubAgent = "sub_agent" + SourceTypeExec = "exec" +) + +const ( + ThreadStatusIdle = "idle" + ThreadStatusActive = "active" +) + +// SessionSource describes how a thread was created. +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"` +} + +// Thread represents a v2 thread object within notifications. +type Thread struct { + ID string `json:"id"` + Status string `json:"status"` + Source SessionSource `json:"source"` +} + +// ThreadStartedNotification is the params payload for thread/started. +type ThreadStartedNotification struct { + Thread Thread `json:"thread"` +} diff --git a/internal/appserver/types_thread_v2_test.go b/internal/appserver/types_thread_v2_test.go new file mode 100644 index 0000000..34975a1 --- /dev/null +++ b/internal/appserver/types_thread_v2_test.go @@ -0,0 +1,68 @@ +package appserver + +import ( + "encoding/json" + "testing" +) + +const ( + v2UnmarshalFailFormat = "v2 unmarshal: %v" + expectedThreadID = "t-1" + expectedThreadIDFormat = "expected t-1, got %s" +) + +func TestUnmarshalThreadStarted(test *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 { + test.Fatalf(v2UnmarshalFailFormat, err) + } + if notification.Thread.ID != expectedThreadID { + test.Errorf(expectedThreadIDFormat, notification.Thread.ID) + } + if notification.Thread.Source.Type != SourceTypeSubAgent { + test.Errorf("expected sub_agent source, got %s", notification.Thread.Source.Type) + } + if notification.Thread.Source.ParentThreadID != "t-0" { + test.Errorf("expected parent t-0, got %s", notification.Thread.Source.ParentThreadID) + } +} + +func TestUnmarshalThreadStartedCLISource(test *testing.T) { + raw := `{"thread": {"id": "t-1", "status": "idle", "source": {"type": "cli"}}}` + var notification ThreadStartedNotification + if err := json.Unmarshal([]byte(raw), ¬ification); err != nil { + test.Fatalf(v2UnmarshalFailFormat, err) + } + if notification.Thread.Source.Type != SourceTypeCLI { + test.Errorf("expected cli source, got %s", notification.Thread.Source.Type) + } +} + +func TestUnmarshalTurnStarted(test *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 { + test.Fatalf(v2UnmarshalFailFormat, err) + } + if notification.ThreadID != expectedThreadID { + test.Errorf(expectedThreadIDFormat, notification.ThreadID) + } +} + +func TestUnmarshalTurnCompleted(test *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 { + test.Fatalf(v2UnmarshalFailFormat, err) + } + if notification.ThreadID != expectedThreadID { + test.Errorf(expectedThreadIDFormat, notification.ThreadID) + } +} diff --git a/internal/appserver/types_turn_v2.go b/internal/appserver/types_turn_v2.go new file mode 100644 index 0000000..390274f --- /dev/null +++ b/internal/appserver/types_turn_v2.go @@ -0,0 +1,26 @@ +package appserver + +type threadScoped struct { + ThreadID string `json:"thread_id"` +} + +// Turn represents a v2 turn object within notifications. +type Turn struct { + ID string `json:"id"` + Status string `json:"status"` +} + +type turnNotification struct { + threadScoped + Turn Turn `json:"turn"` +} + +// TurnStartedNotification is the params payload for turn/started. +type TurnStartedNotification struct { + turnNotification +} + +// TurnCompletedNotification is the params payload for turn/completed. +type TurnCompletedNotification struct { + turnNotification +} diff --git a/internal/state/store.go b/internal/state/store.go index 0c013f2..2937109 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -36,6 +36,19 @@ func (store *ThreadStore) AddWithParent(id string, title string, parentID string store.order = append(store.order, id) } +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) +} + func (store *ThreadStore) Get(id string) (*ThreadState, bool) { store.mu.RLock() defer store.mu.RUnlock() @@ -130,6 +143,32 @@ func (store *ThreadStore) Roots() []*ThreadState { return roots } +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 +} + func removeFromSlice(slice []string, target string) []string { result := make([]string, 0, len(slice)) for _, item := range slice { diff --git a/internal/state/store_test.go b/internal/state/store_test.go index f765092..b16b3bc 100644 --- a/internal/state/store_test.go +++ b/internal/state/store_test.go @@ -3,103 +3,189 @@ package state import "testing" const ( - storeTestThreadID = "t-1" - storeTestSecondID = "t-2" + storeTestThreadID1 = "t-1" + storeTestThreadID2 = "t-2" + storeTestParentID = "t-0" storeTestMissingID = "missing" - storeTestMyThread = "My Thread" - storeTestTitle = "Test" - storeTestFirstTitle = "First" - storeTestSecondTitle = "Second" + storeTestTitleThread = "My Thread" + storeTestTitleTest = "Test" + storeTestTitleFirst = "First" + storeTestTitleSecond = "Second" + storeTestTitleRoot = "Root" + storeTestTitleChild = "Child" + storeTestTitleScout = "Scout" storeTestRunning = "Running" storeTestActivity = "Running: git status" + storeTestNickname = "scout" + storeTestRole = "researcher" + storeTestChildNotFnd = "child not found" storeTestExpectedTwo = 2 + storeTestPositionFmt = "position %d: expected %s, got %s" + + treeTestRoot1 = "root-1" + treeTestRoot2 = "root-2" + treeTestChild1a = "child-1a" + treeTestChild1b = "child-1b" + treeTestChild2a = "child-2a" + treeTestRootID = "root" + treeTestChildID = "child" + treeTestGrandChild = "grandchild" ) -func TestStoreAddAndGet(testing *testing.T) { +func TestStoreAddAndGet(test *testing.T) { store := NewThreadStore() - store.Add(storeTestThreadID, storeTestMyThread) + store.Add(storeTestThreadID1, storeTestTitleThread) - thread, exists := store.Get(storeTestThreadID) + thread, exists := store.Get(storeTestThreadID1) if !exists { - testing.Fatal("expected thread to exist") + test.Fatal("expected thread to exist") } - if thread.Title != storeTestMyThread { - testing.Errorf("expected My Thread, got %s", thread.Title) + if thread.Title != storeTestTitleThread { + test.Errorf("expected My Thread, got %s", thread.Title) } } -func TestStoreGetMissing(testing *testing.T) { +func TestStoreGetMissing(test *testing.T) { store := NewThreadStore() _, exists := store.Get(storeTestMissingID) if exists { - testing.Error("expected thread to not exist") + test.Error("expected thread to not exist") } } -func TestStoreDelete(testing *testing.T) { +func TestStoreDelete(test *testing.T) { store := NewThreadStore() - store.Add(storeTestThreadID, storeTestTitle) - store.Delete(storeTestThreadID) + store.Add(storeTestThreadID1, storeTestTitleTest) + store.Delete(storeTestThreadID1) - _, exists := store.Get(storeTestThreadID) + _, exists := store.Get(storeTestThreadID1) if exists { - testing.Error("expected thread to be deleted") + test.Error("expected thread to be deleted") } } -func TestStoreAll(testing *testing.T) { +func TestStoreAll(test *testing.T) { store := NewThreadStore() - store.Add(storeTestThreadID, storeTestFirstTitle) - store.Add(storeTestSecondID, storeTestSecondTitle) + store.Add(storeTestThreadID1, storeTestTitleFirst) + store.Add(storeTestThreadID2, storeTestTitleSecond) all := store.All() if len(all) != storeTestExpectedTwo { - testing.Fatalf("expected 2 threads, got %d", len(all)) + test.Fatalf("expected 2 threads, got %d", len(all)) } } -func TestStoreUpdateStatus(testing *testing.T) { +func TestStoreUpdateStatus(test *testing.T) { store := NewThreadStore() - store.Add(storeTestThreadID, storeTestTitle) - store.UpdateStatus(storeTestThreadID, StatusActive, storeTestRunning) + store.Add(storeTestThreadID1, storeTestTitleTest) + store.UpdateStatus(storeTestThreadID1, StatusActive, storeTestRunning) - thread, _ := store.Get(storeTestThreadID) + thread, _ := store.Get(storeTestThreadID1) if thread.Status != StatusActive { - testing.Errorf("expected active, got %s", thread.Status) + test.Errorf("expected active, got %s", thread.Status) } if thread.Title != storeTestRunning { - testing.Errorf("expected Running, got %s", thread.Title) + test.Errorf("expected Running, got %s", thread.Title) } } -func TestStoreUpdateStatusMissing(testing *testing.T) { +func TestStoreUpdateStatusMissing(test *testing.T) { store := NewThreadStore() - store.UpdateStatus(storeTestMissingID, StatusActive, storeTestTitle) + store.UpdateStatus(storeTestMissingID, StatusActive, storeTestTitleTest) } -func TestStoreIDs(testing *testing.T) { +func TestStoreIDs(test *testing.T) { store := NewThreadStore() - store.Add(storeTestThreadID, storeTestFirstTitle) - store.Add(storeTestSecondID, storeTestSecondTitle) + store.Add(storeTestThreadID1, storeTestTitleFirst) + store.Add(storeTestThreadID2, storeTestTitleSecond) ids := store.IDs() if len(ids) != storeTestExpectedTwo { - testing.Fatalf("expected 2 ids, got %d", len(ids)) + test.Fatalf("expected 2 ids, got %d", len(ids)) } } -func TestStoreUpdateActivity(testing *testing.T) { +func TestAddWithParentFields(test *testing.T) { store := NewThreadStore() - store.Add(storeTestThreadID, storeTestTitle) - store.UpdateActivity(storeTestThreadID, storeTestActivity) + store.Add(storeTestParentID, storeTestTitleRoot) + store.AddWithParent(storeTestThreadID1, storeTestTitleChild, storeTestParentID) - thread, _ := store.Get(storeTestThreadID) + child, exists := store.Get(storeTestThreadID1) + if !exists { + test.Fatal(storeTestChildNotFnd) + } + if child.ParentID != storeTestParentID { + test.Errorf("expected parent t-0, got %s", child.ParentID) + } +} + +func TestAddSubAgent(test *testing.T) { + store := NewThreadStore() + store.Add(storeTestParentID, storeTestTitleRoot) + store.AddSubAgent(storeTestThreadID1, storeTestTitleScout, storeTestParentID, storeTestNickname, storeTestRole, 1) + + child, exists := store.Get(storeTestThreadID1) + if !exists { + test.Fatal(storeTestChildNotFnd) + } + if child.AgentNickname != storeTestNickname { + test.Errorf("expected scout, got %s", child.AgentNickname) + } + if child.AgentRole != storeTestRole { + test.Errorf("expected researcher, got %s", child.AgentRole) + } + if child.Depth != 1 { + test.Errorf("expected depth 1, got %d", child.Depth) + } +} + +func TestTreeOrder(test *testing.T) { + store := NewThreadStore() + store.Add(treeTestRoot1, "Root 1") + store.Add(treeTestRoot2, "Root 2") + store.AddWithParent(treeTestChild1a, "Child 1a", treeTestRoot1) + store.AddWithParent(treeTestChild1b, "Child 1b", treeTestRoot1) + store.AddWithParent(treeTestChild2a, "Child 2a", treeTestRoot2) + + ordered := store.TreeOrder() + expectedOrder := []string{treeTestRoot1, treeTestChild1a, treeTestChild1b, treeTestRoot2, treeTestChild2a} + if len(ordered) != len(expectedOrder) { + test.Fatalf("expected %d threads, got %d", len(expectedOrder), len(ordered)) + } + for index, thread := range ordered { + if thread.ID != expectedOrder[index] { + test.Errorf(storeTestPositionFmt, index, expectedOrder[index], thread.ID) + } + } +} + +func TestTreeOrderNestedChildren(test *testing.T) { + store := NewThreadStore() + store.Add(treeTestRootID, storeTestTitleRoot) + store.AddWithParent(treeTestChildID, storeTestTitleChild, treeTestRootID) + store.AddWithParent(treeTestGrandChild, "Grandchild", treeTestChildID) + + ordered := store.TreeOrder() + expectedOrder := []string{treeTestRootID, treeTestChildID, treeTestGrandChild} + for index, thread := range ordered { + if thread.ID != expectedOrder[index] { + test.Errorf(storeTestPositionFmt, index, expectedOrder[index], thread.ID) + } + } +} + +func TestStoreUpdateActivity(test *testing.T) { + store := NewThreadStore() + store.Add(storeTestThreadID1, storeTestTitleTest) + store.UpdateActivity(storeTestThreadID1, storeTestActivity) + + thread, _ := store.Get(storeTestThreadID1) if thread.Activity != storeTestActivity { - testing.Errorf("expected %s, got %s", storeTestActivity, thread.Activity) + test.Errorf("expected %s, got %s", storeTestActivity, thread.Activity) } } -func TestStoreUpdateActivityMissing(testing *testing.T) { +func TestStoreUpdateActivityMissing(test *testing.T) { store := NewThreadStore() store.UpdateActivity(storeTestMissingID, storeTestActivity) } diff --git a/internal/state/thread.go b/internal/state/thread.go index 8b21d81..886c2d6 100644 --- a/internal/state/thread.go +++ b/internal/state/thread.go @@ -19,6 +19,9 @@ type ThreadState struct { Status string Activity string ParentID string + AgentNickname string + AgentRole string + Depth int Messages []ChatMessage CommandOutput map[string]string } diff --git a/internal/tui/app.go b/internal/tui/app.go index 42f8b2c..28d39ae 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1,8 +1,6 @@ package tui import ( - "fmt" - tea "github.com/charmbracelet/bubbletea" "github.com/robinojw/dj/internal/appserver" "github.com/robinojw/dj/internal/state" @@ -30,9 +28,7 @@ type AppModel struct { canvasMode int width int height int - sessionID string - currentMessageID string - events chan appserver.ProtoEvent + events chan appserver.JSONRPCMessage ptySessions map[string]*PTYSession ptyEvents chan PTYOutputMsg sessionCounter *int @@ -50,7 +46,7 @@ func NewAppModel(store *state.ThreadStore, opts ...AppOption) AppModel { tree: NewTreeModel(store), prefix: NewPrefixHandler(), help: NewHelpModel(), - events: make(chan appserver.ProtoEvent, eventChannelSize), + events: make(chan appserver.JSONRPCMessage, eventChannelSize), ptySessions: make(map[string]*PTYSession), ptyEvents: make(chan PTYOutputMsg, eventChannelSize), sessionCounter: new(int), @@ -109,8 +105,8 @@ func (app AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return app.handleMouse(msg) case tea.WindowSizeMsg: return app.handleWindowSize(msg) - case protoEventMsg: - return app.handleProtoEvent(msg.Event) + case jsonRPCEventMsg: + return app.handleProtoEvent(msg.Message) case PTYOutputMsg: return app.handlePTYOutput(msg) case AppServerErrorMsg: @@ -140,156 +136,24 @@ func (app AppModel) handleThreadCreated(msg ThreadCreatedMsg) (tea.Model, tea.Cm func (app AppModel) handleAgentMsg(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case SessionConfiguredMsg: - return app.handleSessionConfigured(msg) - case TaskStartedMsg: - return app.handleTaskStarted() - case AgentDeltaMsg: - return app.handleAgentDelta(msg) - case AgentMessageCompletedMsg: - return app.handleAgentMessageCompleted() - case TaskCompleteMsg: - return app.handleTaskComplete() - case ExecApprovalRequestMsg: - return app.handleExecApproval(msg) - case PatchApprovalRequestMsg: - return app.handlePatchApproval(msg) - case AgentReasoningDeltaMsg: - return app.handleReasoningDelta() - } - return app, nil -} - -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) - } - - if result, model, cmd := app.handlePrefix(msg); result { - return model, cmd - } - - if app.focusPane == FocusPaneSession { - return app.handleSessionKey(msg) - } - - return app.handleCanvasKey(msg) -} - -func (app AppModel) handlePrefix(msg tea.KeyMsg) (bool, tea.Model, tea.Cmd) { - prefixResult := app.prefix.HandleKey(msg) - switch prefixResult { - case PrefixWaiting: - return true, app, nil - case PrefixComplete: - model, cmd := app.handlePrefixAction() - return true, model, cmd - case PrefixCancelled: - return true, app, nil - } - return false, app, nil -} - -func (app AppModel) handleCanvasKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyCtrlC, tea.KeyEsc: - return app, tea.Quit - 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) 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 " ", "s": - return app.togglePin() - case "k": - return app.killSession() - } - return app, nil -} - -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), - } - } -} - -func (app AppModel) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - isToggle := msg.Type == tea.KeyRunes && msg.String() == "?" - isEsc := msg.Type == tea.KeyEsc - shouldDismissHelp := isToggle || isEsc - if shouldDismissHelp { - app.helpVisible = false - } - return app, nil -} - -func (app *AppModel) toggleCanvasMode() { - if app.canvasMode == CanvasModeGrid { - app.canvasMode = CanvasModeTree - return - } - app.canvasMode = CanvasModeGrid -} - -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 -} - -func (app AppModel) handleArrow(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if app.canvasMode == CanvasModeTree { - app.handleTreeArrow(msg) - return app, nil + 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: + return app.handleThreadStatusChanged(msg) + case V2ExecApprovalMsg: + return app.handleV2ExecApproval(msg) + case V2FileApprovalMsg: + return app.handleV2FileApproval(msg) } - app.handleCanvasArrow(msg) return app, nil } - -func (app *AppModel) handleTreeArrow(msg tea.KeyMsg) { - switch msg.Type { - case tea.KeyDown: - app.tree.MoveDown() - case tea.KeyUp: - app.tree.MoveUp() - } -} - -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() - } -} diff --git a/internal/tui/app_keys.go b/internal/tui/app_keys.go new file mode 100644 index 0000000..fc2ba5c --- /dev/null +++ b/internal/tui/app_keys.go @@ -0,0 +1,141 @@ +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +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) + } + + if result, model, cmd := app.handlePrefix(msg); result { + return model, cmd + } + + if app.focusPane == FocusPaneSession { + return app.handleSessionKey(msg) + } + + return app.handleCanvasKey(msg) +} + +func (app AppModel) handlePrefix(msg tea.KeyMsg) (bool, tea.Model, tea.Cmd) { + prefixResult := app.prefix.HandleKey(msg) + switch prefixResult { + case PrefixWaiting: + return true, app, nil + case PrefixComplete: + model, cmd := app.handlePrefixAction() + return true, model, cmd + case PrefixCancelled: + return true, app, nil + } + return false, app, nil +} + +func (app AppModel) handleCanvasKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return app, tea.Quit + 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) 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 " ", "s": + return app.togglePin() + case "k": + return app.killSession() + } + return app, nil +} + +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), + } + } +} + +func (app AppModel) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + isToggle := msg.Type == tea.KeyRunes && msg.String() == "?" + isEsc := msg.Type == tea.KeyEsc + shouldDismissHelp := isToggle || isEsc + if shouldDismissHelp { + app.helpVisible = false + } + return app, nil +} + +func (app *AppModel) toggleCanvasMode() { + if app.canvasMode == CanvasModeGrid { + app.canvasMode = CanvasModeTree + return + } + app.canvasMode = CanvasModeGrid +} + +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 +} + +func (app AppModel) handleArrow(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if app.canvasMode == CanvasModeTree { + app.handleTreeArrow(msg) + return app, nil + } + app.handleCanvasArrow(msg) + return app, nil +} + +func (app *AppModel) handleTreeArrow(msg tea.KeyMsg) { + switch msg.Type { + case tea.KeyDown: + app.tree.MoveDown() + case tea.KeyUp: + app.tree.MoveUp() + } +} + +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() + } +} diff --git a/internal/tui/app_kill_test.go b/internal/tui/app_kill_test.go index e88b2a7..4a3178f 100644 --- a/internal/tui/app_kill_test.go +++ b/internal/tui/app_kill_test.go @@ -7,12 +7,12 @@ import ( "github.com/robinojw/dj/internal/state" ) -const errExpectedRemaining = "expected %s remaining, got %s" +const killTestExpectedRemaining = "expected %s remaining, got %s" func TestAppKillSessionRemovesThread(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testTitleThread1) - store.Add(testThreadID2, testTitleThread2) + store.Add(appTestThreadID1, appTestTitleThread1) + store.Add(appTestThreadID2, appTestTitleThread2) app := NewAppModel(store) kKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}} @@ -23,16 +23,16 @@ func TestAppKillSessionRemovesThread(test *testing.T) { if len(threads) != 1 { test.Fatalf("expected 1 thread after kill, got %d", len(threads)) } - if threads[0].ID != testThreadID2 { - test.Errorf(errExpectedRemaining, testThreadID2, threads[0].ID) + if threads[0].ID != appTestThreadID2 { + test.Errorf(killTestExpectedRemaining, appTestThreadID2, threads[0].ID) } _ = appModel } func TestAppKillSessionStopsPTY(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testTitleThread1) - app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + store.Add(appTestThreadID1, appTestTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdCat)) enterKey := tea.KeyMsg{Type: tea.KeyEnter} updated, _ := app.Update(enterKey) @@ -53,8 +53,8 @@ func TestAppKillSessionStopsPTY(test *testing.T) { func TestAppKillSessionUnpins(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testTitleThread1) - app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + store.Add(appTestThreadID1, appTestTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdCat)) spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} updated, _ := app.Update(spaceKey) @@ -75,8 +75,8 @@ func TestAppKillSessionUnpins(test *testing.T) { func TestAppKillSessionClampsSelection(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testTitleThread1) - store.Add(testThreadID2, testTitleThread2) + store.Add(appTestThreadID1, appTestTitleThread1) + store.Add(appTestThreadID2, appTestTitleThread2) app := NewAppModel(store) rightKey := tea.KeyMsg{Type: tea.KeyRight} @@ -108,8 +108,8 @@ func TestAppKillSessionWithNoThreadsDoesNothing(test *testing.T) { func TestAppKillSessionReturnsFocusToCanvas(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testTitleThread1) - app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + store.Add(appTestThreadID1, appTestTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdCat)) enterKey := tea.KeyMsg{Type: tea.KeyEnter} updated, _ := app.Update(enterKey) @@ -130,8 +130,8 @@ func TestAppKillSessionReturnsFocusToCanvas(test *testing.T) { func TestAppKillSessionInTreeMode(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testThreadTitle1) - store.Add(testThreadID2, testThreadTitle2) + store.Add(appTestThreadID1, appTestTitleFirst) + store.Add(appTestThreadID2, appTestTitleSecond) app := NewAppModel(store) tKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'t'}} @@ -150,7 +150,7 @@ func TestAppKillSessionInTreeMode(test *testing.T) { if len(threads) != 1 { test.Fatalf("expected 1 thread after kill in tree mode, got %d", len(threads)) } - if threads[0].ID != testThreadID1 { - test.Errorf(errExpectedRemaining, testThreadID1, threads[0].ID) + if threads[0].ID != appTestThreadID1 { + test.Errorf(killTestExpectedRemaining, appTestThreadID1, threads[0].ID) } } diff --git a/internal/tui/app_pane_test.go b/internal/tui/app_pane_test.go new file mode 100644 index 0000000..07df7fc --- /dev/null +++ b/internal/tui/app_pane_test.go @@ -0,0 +1,227 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/state" +) + +func TestAppCtrlBXUnpinsSession(test *testing.T) { + store := state.NewThreadStore() + store.Add(appTestThreadID1, appTestTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdEcho, appTestArgHello)) + + 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 { + test.Errorf(appTestExpected0Pinned, len(app.sessionPanel.PinnedSessions())) + } + if app.FocusPane() != FocusPaneCanvas { + test.Errorf("expected focus back to canvas, got %d", app.FocusPane()) + } +} + +func TestAppCtrlBZTogglesZoom(test *testing.T) { + store := state.NewThreadStore() + store.Add(appTestThreadID1, appTestTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdEcho, appTestArgHello)) + + spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} + updated, _ := app.Update(spaceKey) + 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() { + test.Error("expected zoomed after Ctrl+B z") + } +} + +func TestAppCtrlBRightCyclesPaneRight(test *testing.T) { + store := state.NewThreadStore() + store.Add(appTestThreadID1, appTestTitleThread1) + store.Add(appTestThreadID2, appTestTitleThread2) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdEcho, appTestArgHello)) + + 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() != appTestExpectedPaneIndex0 { + test.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() != appTestExpectedPaneIndex1 { + test.Errorf("expected active pane 1, got %d", app.sessionPanel.ActivePaneIdx()) + } +} + +func TestAppViewShowsDividerWhenPinned(test *testing.T) { + store := state.NewThreadStore() + store.Add(appTestThreadID1, appTestTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdEcho, appTestArgHello)) + app.width = appTestWidth + app.height = appTestHeight + + spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} + updated, _ := app.Update(spaceKey) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + view := app.View() + hasDivider := strings.Contains(view, "\u2500") + if !hasDivider { + test.Error("expected divider line in view when sessions pinned") + } +} + +func TestHelpShowsPinKeybinding(test *testing.T) { + help := NewHelpModel() + view := help.View() + if !strings.Contains(view, "Space") { + test.Error("expected Space keybinding in help") + } + if !strings.Contains(view, "Ctrl+B x") { + test.Error("expected Ctrl+B x keybinding in help") + } + if !strings.Contains(view, "Ctrl+B z") { + test.Error("expected Ctrl+B z keybinding in help") + } +} + +func TestHelpShowsKillKeybinding(test *testing.T) { + help := NewHelpModel() + view := help.View() + if !strings.Contains(view, "Kill") { + test.Error("expected Kill keybinding in help") + } +} + +func TestAppFocusPaneDefaultsToCanvas(test *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + + if app.FocusPane() != FocusPaneCanvas { + test.Errorf(appTestExpectCanvasFocus, app.FocusPane()) + } +} + +func TestAppHasPinnedSessions(test *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + + if len(app.sessionPanel.PinnedSessions()) != 0 { + test.Errorf("expected 0 pinned sessions, got %d", len(app.sessionPanel.PinnedSessions())) + } +} + +func TestAppNewThreadCreatesAndOpensSession(test *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store, WithInteractiveCommand(appTestCmdCat)) + app.width = appTestWidth + app.height = appTestHeight + + nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} + updated, cmd := app.Update(nKey) + app = updated.(AppModel) + + if cmd == nil { + test.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 { + test.Fatalf(appTestExpected1Thread, len(threads)) + } + + if app.FocusPane() != FocusPaneSession { + test.Errorf("expected session focus after new thread, got %d", app.FocusPane()) + } + + if len(app.sessionPanel.PinnedSessions()) != 1 { + test.Errorf(appTestExpected1Pinned, len(app.sessionPanel.PinnedSessions())) + } +} + +func TestAppNewThreadIncrementsTitle(test *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store, WithInteractiveCommand(appTestCmdCat)) + app.width = appTestWidth + app.height = appTestHeight + + nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} + escKey := tea.KeyMsg{Type: tea.KeyEsc} + + updated, cmd := app.Update(nKey) + app = updated.(AppModel) + msg := cmd() + updated, _ = app.Update(msg) + app = updated.(AppModel) + + updated, _ = app.Update(escKey) + app = updated.(AppModel) + + updated, cmd = app.Update(nKey) + app = updated.(AppModel) + msg = cmd() + updated, _ = app.Update(msg) + app = updated.(AppModel) + defer app.StopAllPTYSessions() + + threads := store.All() + if len(threads) != appTestExpectedThreads { + test.Fatalf("expected 2 threads, got %d", len(threads)) + } + if threads[0].Title != appTestTitleSession1 { + test.Errorf("expected 'Session 1', got %s", threads[0].Title) + } + if threads[1].Title != appTestTitleSession2 { + test.Errorf("expected 'Session 2', got %s", threads[1].Title) + } +} diff --git a/internal/tui/app_prefix_test.go b/internal/tui/app_prefix_test.go deleted file mode 100644 index ad851f4..0000000 --- a/internal/tui/app_prefix_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/robinojw/dj/internal/state" -) - -func TestAppCtrlBMOpensMenu(test *testing.T) { - store := state.NewThreadStore() - store.Add(testThreadID1, testTitleTest) - - 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() { - test.Error("expected menu to be visible") - } -} - -func TestAppMenuEscCloses(test *testing.T) { - store := state.NewThreadStore() - store.Add(testThreadID1, testTitleTest) - - 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) - - escKey := tea.KeyMsg{Type: tea.KeyEsc} - updated, _ = app.Update(escKey) - app = updated.(AppModel) - - if app.MenuVisible() { - test.Error("expected menu hidden after Esc") - } -} - -func TestAppCtrlBEscCancelsPrefix(test *testing.T) { - store := state.NewThreadStore() - store.Add(testThreadID1, testTitleTest) - - app := NewAppModel(store) - - ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} - updated, _ := app.Update(ctrlB) - app = updated.(AppModel) - - escKey := tea.KeyMsg{Type: tea.KeyEsc} - updated, _ = app.Update(escKey) - app = updated.(AppModel) - - if app.MenuVisible() { - test.Error("expected menu not visible after prefix cancel") - } -} - -func TestAppMenuNavigation(test *testing.T) { - store := state.NewThreadStore() - store.Add(testThreadID1, testTitleTest) - - 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) - - downKey := tea.KeyMsg{Type: tea.KeyDown} - updated, _ = app.Update(downKey) - app = updated.(AppModel) - - if app.menu.SelectedIndex() != 1 { - test.Errorf("expected menu index 1, got %d", app.menu.SelectedIndex()) - } -} - -func TestAppCtrlBXUnpinsSession(test *testing.T) { - store := state.NewThreadStore() - store.Add(testThreadID1, testTitleThread1) - app := NewAppModel(store, WithInteractiveCommand(testCommandEcho, testArgHello)) - - 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 { - test.Errorf("expected 0 pinned after unpin, got %d", len(app.sessionPanel.PinnedSessions())) - } - if app.FocusPane() != FocusPaneCanvas { - test.Errorf("expected focus back to canvas, got %d", app.FocusPane()) - } -} - -func TestAppCtrlBZTogglesZoom(test *testing.T) { - store := state.NewThreadStore() - store.Add(testThreadID1, testTitleThread1) - app := NewAppModel(store, WithInteractiveCommand(testCommandEcho, testArgHello)) - - spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} - updated, _ := app.Update(spaceKey) - 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() { - test.Error("expected zoomed after Ctrl+B z") - } -} - -func TestAppCtrlBRightCyclesPaneRight(test *testing.T) { - store := state.NewThreadStore() - store.Add(testThreadID1, testTitleThread1) - store.Add(testThreadID2, testTitleThread2) - app := NewAppModel(store, WithInteractiveCommand(testCommandEcho, testArgHello)) - - 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 { - test.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 { - test.Errorf("expected active pane 1, got %d", app.sessionPanel.ActivePaneIdx()) - } -} - -func TestAppViewShowsDividerWhenPinned(test *testing.T) { - store := state.NewThreadStore() - store.Add(testThreadID1, testTitleThread1) - app := NewAppModel(store, WithInteractiveCommand(testCommandEcho, testArgHello)) - app.width = testAppWidth - app.height = testAppHeight - - 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, "─") { - test.Error("expected divider line in view when sessions pinned") - } -} diff --git a/internal/tui/app_proto.go b/internal/tui/app_proto.go index f4b38a3..a5bab87 100644 --- a/internal/tui/app_proto.go +++ b/internal/tui/app_proto.go @@ -3,11 +3,9 @@ package tui import ( "context" "fmt" - "time" tea "github.com/charmbracelet/bubbletea" "github.com/robinojw/dj/internal/appserver" - "github.com/robinojw/dj/internal/state" ) const ( @@ -17,8 +15,8 @@ const ( activitySnippetMaxLen = 40 ) -type protoEventMsg struct { - Event appserver.ProtoEvent +type jsonRPCEventMsg struct { + Message appserver.JSONRPCMessage } func (app AppModel) connectClient() tea.Cmd { @@ -27,8 +25,8 @@ func (app AppModel) connectClient() tea.Cmd { if err := app.client.Start(ctx); err != nil { return AppServerErrorMsg{Err: err} } - go app.client.ReadLoop(func(event appserver.ProtoEvent) { - app.events <- event + go app.client.ReadLoop(func(message appserver.JSONRPCMessage) { + app.events <- message }) return nil } @@ -36,16 +34,16 @@ func (app AppModel) connectClient() tea.Cmd { func (app AppModel) listenForEvents() tea.Cmd { return func() tea.Msg { - event, ok := <-app.events + message, ok := <-app.events if !ok { return AppServerErrorMsg{Err: fmt.Errorf("connection closed")} } - return protoEventMsg{Event: event} + return jsonRPCEventMsg{Message: message} } } -func (app AppModel) handleProtoEvent(event appserver.ProtoEvent) (tea.Model, tea.Cmd) { - tuiMsg := ProtoEventToMsg(event) +func (app AppModel) handleProtoEvent(message appserver.JSONRPCMessage) (tea.Model, tea.Cmd) { + tuiMsg := V2MessageToMsg(message) if tuiMsg == nil { return app, app.listenForEvents() } @@ -53,108 +51,3 @@ func (app AppModel) handleProtoEvent(event appserver.ProtoEvent) (tea.Model, tea resultApp := updated.(AppModel) return resultApp, tea.Batch(innerCmd, resultApp.listenForEvents()) } - -func (app AppModel) handleSessionConfigured(msg SessionConfiguredMsg) (tea.Model, tea.Cmd) { - app.sessionID = msg.SessionID - title := msg.Model - if title == "" { - title = "Codex Session" - } - app.store.Add(msg.SessionID, title) - app.statusBar.SetConnected(true) - app.statusBar.SetThreadCount(len(app.store.All())) - return app, nil -} - -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, activityThinking) - 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 -} - -func (app AppModel) handleAgentDelta(msg AgentDeltaMsg) (tea.Model, tea.Cmd) { - missingContext := app.sessionID == "" || app.currentMessageID == "" - if missingContext { - 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 -} - -func (app AppModel) handleReasoningDelta() (tea.Model, tea.Cmd) { - if app.sessionID != "" { - app.store.UpdateActivity(app.sessionID, activityThinking) - } - return app, nil -} - -func (app AppModel) handleAgentMessageCompleted() (tea.Model, tea.Cmd) { - app.currentMessageID = "" - if app.sessionID != "" { - app.store.UpdateActivity(app.sessionID, "") - } - return app, nil -} - -func (app AppModel) handleTaskComplete() (tea.Model, tea.Cmd) { - if app.sessionID != "" { - app.store.UpdateStatus(app.sessionID, state.StatusCompleted, "") - app.store.UpdateActivity(app.sessionID, "") - } - app.currentMessageID = "" - return app, nil -} - -func (app AppModel) handleExecApproval(msg ExecApprovalRequestMsg) (tea.Model, tea.Cmd) { - if app.sessionID != "" { - activity := activityRunningPrefix + msg.Command - app.store.UpdateActivity(app.sessionID, activity) - } - if app.client != nil { - app.client.SendApproval(msg.EventID, appserver.OpExecApproval, true) - } - return app, nil -} - -func (app AppModel) handlePatchApproval(msg PatchApprovalRequestMsg) (tea.Model, tea.Cmd) { - if app.sessionID != "" { - app.store.UpdateActivity(app.sessionID, activityApplyingPatch) - } - if app.client != nil { - app.client.SendApproval(msg.EventID, appserver.OpPatchApproval, true) - } - return app, nil -} - -func latestMessageSnippet(thread *state.ThreadState, messageID string) string { - for index := range thread.Messages { - if thread.Messages[index].ID != messageID { - continue - } - content := thread.Messages[index].Content - if len(content) <= activitySnippetMaxLen { - return content - } - return content[len(content)-activitySnippetMaxLen:] - } - return "" -} diff --git a/internal/tui/app_proto_v2.go b/internal/tui/app_proto_v2.go new file mode 100644 index 0000000..626233a --- /dev/null +++ b/internal/tui/app_proto_v2.go @@ -0,0 +1,99 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/appserver" + "github.com/robinojw/dj/internal/state" +) + +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, "") + app.store.UpdateActivity(msg.ThreadID, activityThinking) + return app, nil +} + +func (app AppModel) handleTurnCompleted(msg TurnCompletedMsg) (tea.Model, tea.Cmd) { + app.store.UpdateStatus(msg.ThreadID, state.StatusCompleted, "") + app.store.UpdateActivity(msg.ThreadID, "") + 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) + snippet := v2DeltaSnippet(msg.Delta) + app.store.UpdateActivity(msg.ThreadID, snippet) + return app, nil +} + +func v2DeltaSnippet(delta string) string { + if len(delta) <= activitySnippetMaxLen { + return delta + } + return delta[len(delta)-activitySnippetMaxLen:] +} + +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 +} + +func (app AppModel) handleThreadStatusChanged(msg ThreadStatusChangedMsg) (tea.Model, tea.Cmd) { + agentStatus := mapAgentStatusToDJ(msg.Status) + app.store.UpdateStatus(msg.ThreadID, agentStatus, "") + return app, nil +} + +func (app AppModel) handleV2ExecApproval(msg V2ExecApprovalMsg) (tea.Model, tea.Cmd) { + activity := activityRunningPrefix + msg.Command + app.store.UpdateActivity(msg.ThreadID, activity) + if app.client != nil { + app.client.SendApproval(msg.RequestID, true) + } + return app, nil +} + +func (app AppModel) handleV2FileApproval(msg V2FileApprovalMsg) (tea.Model, tea.Cmd) { + app.store.UpdateActivity(msg.ThreadID, activityApplyingPatch) + if app.client != nil { + app.client.SendApproval(msg.RequestID, true) + } + return app, nil +} + +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 +} diff --git a/internal/tui/app_protocol_test.go b/internal/tui/app_protocol_test.go deleted file mode 100644 index ba752ad..0000000 --- a/internal/tui/app_protocol_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package tui - -import ( - "testing" - - "github.com/robinojw/dj/internal/state" -) - -const ( - errExpected1Thread = "expected 1 thread, got %d" - errExpectedThreadID = "expected thread %s, got %s" -) - -func TestAppHandlesSessionConfigured(test *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - msg := SessionConfiguredMsg{ - SessionID: testSessionID1, - Model: testModelName, - } - updated, _ := app.Update(msg) - appModel := updated.(AppModel) - - if appModel.sessionID != testSessionID1 { - test.Errorf("expected sessionID %s, got %s", testSessionID1, appModel.sessionID) - } - - threads := store.All() - if len(threads) != 1 { - test.Fatalf(errExpected1Thread, len(threads)) - } - if threads[0].ID != testSessionID1 { - test.Errorf(errExpectedThreadID, testSessionID1, threads[0].ID) - } -} - -func TestAppHandlesTaskStarted(test *testing.T) { - store := state.NewThreadStore() - store.Add(testSessionID1, testTitleTest) - - app := NewAppModel(store) - app.sessionID = testSessionID1 - - updated, _ := app.Update(TaskStartedMsg{}) - appModel := updated.(AppModel) - - thread, _ := store.Get(testSessionID1) - if thread.Status != state.StatusActive { - test.Errorf("expected active, got %s", thread.Status) - } - if appModel.currentMessageID == "" { - test.Error("expected currentMessageID to be set") - } - if len(thread.Messages) != 1 { - test.Fatalf("expected 1 message, got %d", len(thread.Messages)) - } -} - -func TestAppHandlesAgentDelta(test *testing.T) { - store := state.NewThreadStore() - store.Add(testSessionID1, testTitleTest) - - app := NewAppModel(store) - app.sessionID = testSessionID1 - app.currentMessageID = testMessageID1 - - thread, _ := store.Get(testSessionID1) - thread.AppendMessage(state.ChatMessage{ID: testMessageID1, Role: testRoleAssistant}) - - updated, _ := app.Update(AgentDeltaMsg{Delta: testDeltaHello}) - _ = updated.(AppModel) - - if thread.Messages[0].Content != testDeltaHello { - test.Errorf("expected %s, got %s", testDeltaHello, thread.Messages[0].Content) - } -} - -func TestAppHandlesTaskComplete(test *testing.T) { - store := state.NewThreadStore() - store.Add(testSessionID1, testTitleTest) - - app := NewAppModel(store) - app.sessionID = testSessionID1 - app.currentMessageID = testMessageID1 - - updated, _ := app.Update(TaskCompleteMsg{LastMessage: testLastMessageDone}) - appModel := updated.(AppModel) - - thread, _ := store.Get(testSessionID1) - if thread.Status != state.StatusCompleted { - test.Errorf("expected completed, got %s", thread.Status) - } - if appModel.currentMessageID != "" { - test.Error("expected currentMessageID to be cleared") - } -} - -func TestAppHandlesAgentDeltaWithoutSession(test *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - updated, _ := app.Update(AgentDeltaMsg{Delta: testDeltaTest}) - _ = updated.(AppModel) -} - -func TestAppAutoApprovesExecRequest(test *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - msg := ExecApprovalRequestMsg{EventID: testEventID1, Command: testCommandLS} - updated, _ := app.Update(msg) - _ = updated.(AppModel) -} - -func TestAppHandlesThreadCreatedMsg(test *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) - app.width = testAppWidth - app.height = testAppHeight - - msg := ThreadCreatedMsg{ThreadID: testNewThreadID, Title: testTitleNewThread} - updated, _ := app.Update(msg) - appModel := updated.(AppModel) - defer appModel.StopAllPTYSessions() - - threads := store.All() - if len(threads) != 1 { - test.Fatalf(errExpected1Thread, len(threads)) - } - if threads[0].ID != testNewThreadID { - test.Errorf(errExpectedThreadID, testNewThreadID, threads[0].ID) - } - if appModel.FocusPane() != FocusPaneSession { - test.Errorf("expected session focus, got %d", appModel.FocusPane()) - } -} diff --git a/internal/tui/app_session_test.go b/internal/tui/app_session_test.go index 2379a29..49b77ac 100644 --- a/internal/tui/app_session_test.go +++ b/internal/tui/app_session_test.go @@ -7,17 +7,11 @@ import ( "github.com/robinojw/dj/internal/state" ) -const ( - errExpected1Pinned = "expected 1 pinned session, got %d" - errExpectedSessionFocus = "expected session focus, got %d" - errExpectedStrFmt = "expected %s, got %s" -) - func TestAppEnterOpensSession(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testTitleTestTask) + store.Add(appTestThreadID1, appTestTitleTestTask) - app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdCat)) enterKey := tea.KeyMsg{Type: tea.KeyEnter} updated, _ := app.Update(enterKey) @@ -25,24 +19,24 @@ func TestAppEnterOpensSession(test *testing.T) { defer appModel.StopAllPTYSessions() if appModel.FocusPane() != FocusPaneSession { - test.Errorf(errExpectedSessionFocus, appModel.FocusPane()) + test.Errorf(appTestExpectSessionFocus, appModel.FocusPane()) } if len(appModel.sessionPanel.PinnedSessions()) != 1 { - test.Fatalf(errExpected1Pinned, len(appModel.sessionPanel.PinnedSessions())) + test.Fatalf(appTestExpected1Pinned, len(appModel.sessionPanel.PinnedSessions())) } - _, hasPTY := appModel.ptySessions[testThreadID1] + _, hasPTY := appModel.ptySessions[appTestThreadID1] if !hasPTY { - test.Errorf("expected PTY session for thread %s", testThreadID1) + test.Error("expected PTY session to be stored for thread t-1") } } func TestAppEscClosesSession(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testTitleTestTask) + store.Add(appTestThreadID1, appTestTitleTestTask) - app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdCat)) enterKey := tea.KeyMsg{Type: tea.KeyEnter} updated, _ := app.Update(enterKey) @@ -57,7 +51,7 @@ func TestAppEscClosesSession(test *testing.T) { test.Errorf("expected canvas focus after Esc, got %d", appModel.FocusPane()) } - _, hasPTY := appModel.ptySessions[testThreadID1] + _, hasPTY := appModel.ptySessions[appTestThreadID1] if !hasPTY { test.Error("expected PTY session to stay alive after Esc") } @@ -67,24 +61,11 @@ func TestAppEscClosesSession(test *testing.T) { } } -func TestAppEnterWithNoThreadsDoesNothing(test *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - enterKey := tea.KeyMsg{Type: tea.KeyEnter} - updated, _ := app.Update(enterKey) - appModel := updated.(AppModel) - - if appModel.FocusPane() != FocusPaneCanvas { - test.Errorf("expected canvas focus when no threads, got %d", appModel.FocusPane()) - } -} - func TestAppForwardKeyToPTY(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testTitleTest) + store.Add(appTestThreadID1, appTestTitleTest) - app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdCat)) enterKey := tea.KeyMsg{Type: tea.KeyEnter} updated, _ := app.Update(enterKey) @@ -106,9 +87,9 @@ func TestAppForwardKeyToPTY(test *testing.T) { func TestAppReconnectsExistingPTY(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testTitleTest) + store.Add(appTestThreadID1, appTestTitleTest) - app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdCat)) enterKey := tea.KeyMsg{Type: tea.KeyEnter} updated, _ := app.Update(enterKey) @@ -137,24 +118,24 @@ func TestAppReconnectsExistingPTY(test *testing.T) { func TestAppHandlesPTYOutput(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testTitleTest) + store.Add(appTestThreadID1, appTestTitleTest) - app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdCat)) enterKey := tea.KeyMsg{Type: tea.KeyEnter} updated, _ := app.Update(enterKey) app = updated.(AppModel) defer app.StopAllPTYSessions() - exitMsg := PTYOutputMsg{ThreadID: testThreadID1, Exited: true} + exitMsg := PTYOutputMsg{ThreadID: appTestThreadID1, Exited: true} updated, _ = app.Update(exitMsg) _ = updated.(AppModel) } func TestAppSpacePinsSession(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testTitleThread1) - app := NewAppModel(store, WithInteractiveCommand(testCommandEcho, testArgHello)) + store.Add(appTestThreadID1, appTestTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdEcho, appTestArgHello)) spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} updated, _ := app.Update(spaceKey) @@ -162,35 +143,35 @@ func TestAppSpacePinsSession(test *testing.T) { defer appModel.StopAllPTYSessions() if len(appModel.sessionPanel.PinnedSessions()) != 1 { - test.Fatalf("expected 1 pinned, got %d", len(appModel.sessionPanel.PinnedSessions())) + test.Fatalf(appTestExpected1Pinned, len(appModel.sessionPanel.PinnedSessions())) } - if appModel.sessionPanel.PinnedSessions()[0] != testThreadID1 { - test.Errorf(errExpectedStrFmt, testThreadID1, appModel.sessionPanel.PinnedSessions()[0]) + if appModel.sessionPanel.PinnedSessions()[0] != appTestThreadID1 { + test.Errorf("expected t-1, got %s", appModel.sessionPanel.PinnedSessions()[0]) } } func TestAppSpaceUnpinsSession(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testTitleThread1) - app := NewAppModel(store, WithInteractiveCommand(testCommandEcho, testArgHello)) + store.Add(appTestThreadID1, appTestTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdEcho, appTestArgHello)) 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() + updated, _ = appModel.Update(spaceKey) + appModel = updated.(AppModel) + defer appModel.StopAllPTYSessions() - if len(appModel2.sessionPanel.PinnedSessions()) != 0 { - test.Errorf("expected 0 pinned after unpin, got %d", len(appModel2.sessionPanel.PinnedSessions())) + if len(appModel.sessionPanel.PinnedSessions()) != 0 { + test.Errorf(appTestExpected0Pinned, len(appModel.sessionPanel.PinnedSessions())) } } func TestAppTabSwitchesToSessionPanel(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testTitleThread1) - app := NewAppModel(store, WithInteractiveCommand(testCommandEcho, testArgHello)) + store.Add(appTestThreadID1, appTestTitleThread1) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdEcho, appTestArgHello)) spaceKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} updated, _ := app.Update(spaceKey) @@ -215,76 +196,6 @@ func TestAppTabDoesNothingWithNoPinnedSessions(test *testing.T) { app = updated.(AppModel) if app.FocusPane() != FocusPaneCanvas { - test.Errorf("expected FocusPaneCanvas, got %d", app.FocusPane()) - } -} - -func TestAppNewThreadCreatesAndOpensSession(test *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) - app.width = testAppWidth - app.height = testAppHeight - - nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} - updated, cmd := app.Update(nKey) - app = updated.(AppModel) - - if cmd == nil { - test.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 { - test.Fatalf(errExpected1Thread, len(threads)) - } - - if app.FocusPane() != FocusPaneSession { - test.Errorf(errExpectedSessionFocus, app.FocusPane()) - } - - if len(app.sessionPanel.PinnedSessions()) != 1 { - test.Errorf(errExpected1Pinned, len(app.sessionPanel.PinnedSessions())) - } -} - -func TestAppNewThreadIncrementsTitle(test *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store, WithInteractiveCommand(testCommandCat)) - app.width = testAppWidth - app.height = testAppHeight - - nKey := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}} - escKey := tea.KeyMsg{Type: tea.KeyEsc} - - updated, cmd := app.Update(nKey) - app = updated.(AppModel) - msg := cmd() - updated, _ = app.Update(msg) - app = updated.(AppModel) - - updated, _ = app.Update(escKey) - app = updated.(AppModel) - - updated, cmd = app.Update(nKey) - app = updated.(AppModel) - msg = cmd() - updated, _ = app.Update(msg) - app = updated.(AppModel) - defer app.StopAllPTYSessions() - - threads := store.All() - if len(threads) != testExpectedTwo { - test.Fatalf("expected %d threads, got %d", testExpectedTwo, len(threads)) - } - if threads[0].Title != testTitleSession1 { - test.Errorf(errExpectedStrFmt, testTitleSession1, threads[0].Title) - } - if threads[1].Title != testTitleSession2 { - test.Errorf(errExpectedStrFmt, testTitleSession2, threads[1].Title) + test.Errorf(appTestExpectCanvasFocus, app.FocusPane()) } } diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index e28bcdc..2b68252 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -1,7 +1,6 @@ package tui import ( - "strings" "testing" tea "github.com/charmbracelet/bubbletea" @@ -9,35 +8,39 @@ import ( ) const ( - testSessionID1 = "s-1" - testNewThreadID = "t-new" - testTitleTest = "Test" - testTitleTestTask = "Test Task" - testTitleThread1 = "Thread 1" - testTitleThread2 = "Thread 2" - testTitleNewThread = "New Thread" - testTitleSession1 = "Session 1" - testTitleSession2 = "Session 2" - testCommandCat = "cat" - testCommandEcho = "echo" - testArgHello = "hello" - testMessageID1 = "msg-1" - testModelName = "o4-mini" - testEventID1 = "req-1" - testCommandLS = "ls" - testDeltaHello = "Hello" - testDeltaTest = "test" - testLastMessageDone = "Done" - testRoleAssistant = "assistant" - testAppWidth = 120 - testAppHeight = 40 - testExpectedTwo = 2 + appTestThreadID1 = "t-1" + appTestThreadID2 = "t-2" + appTestThreadNew = "t-new" + appTestTitleFirst = "First" + appTestTitleSecond = "Second" + appTestTitleTest = "Test" + appTestTitleTestTask = "Test Task" + appTestTitleThread1 = "Thread 1" + appTestTitleThread2 = "Thread 2" + appTestTitleNewThread = "New Thread" + appTestTitleSession1 = "Session 1" + appTestTitleSession2 = "Session 2" + appTestCmdCat = "cat" + appTestCmdEcho = "echo" + appTestArgHello = "hello" + appTestWidth = 120 + appTestHeight = 40 + appTestExpectedThreads = 2 + appTestExpectedIndex1 = 1 + appTestExpectedPaneIndex0 = 0 + appTestExpectedPaneIndex1 = 1 + appTestExpected1Pinned = "expected 1 pinned session, got %d" + appTestExpected0Pinned = "expected 0 pinned after unpin, got %d" + appTestExpected1Thread = "expected 1 thread, got %d" + appTestExpectSessionFocus = "expected session focus, got %d" + appTestExpectCanvasFocus = "expected FocusPaneCanvas, got %d" + appTestExpectedStrFmt = "expected %s, got %s" ) func TestAppHandlesArrowKeys(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testThreadTitle1) - store.Add(testThreadID2, testThreadTitle2) + store.Add(appTestThreadID1, appTestTitleFirst) + store.Add(appTestThreadID2, appTestTitleSecond) app := NewAppModel(store) @@ -45,14 +48,14 @@ func TestAppHandlesArrowKeys(test *testing.T) { updated, _ := app.Update(rightKey) appModel := updated.(AppModel) - if appModel.canvas.SelectedIndex() != 1 { + if appModel.canvas.SelectedIndex() != appTestExpectedIndex1 { test.Errorf("expected index 1 after right, got %d", appModel.canvas.SelectedIndex()) } } func TestAppToggleCanvasMode(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testTitleTest) + store.Add(appTestThreadID1, appTestTitleTest) app := NewAppModel(store) @@ -71,8 +74,8 @@ func TestAppToggleCanvasMode(test *testing.T) { func TestAppTreeNavigationWhenFocused(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testThreadTitle1) - store.Add(testThreadID2, testThreadTitle2) + store.Add(appTestThreadID1, appTestTitleFirst) + store.Add(appTestThreadID2, appTestTitleSecond) app := NewAppModel(store) @@ -84,8 +87,8 @@ func TestAppTreeNavigationWhenFocused(test *testing.T) { updated, _ = app.Update(downKey) app = updated.(AppModel) - if app.tree.SelectedID() != testThreadID2 { - test.Errorf("expected tree at %s, got %s", testThreadID2, app.tree.SelectedID()) + if app.tree.SelectedID() != appTestThreadID2 { + test.Errorf("expected tree at t-2, got %s", app.tree.SelectedID()) } } @@ -101,6 +104,103 @@ func TestAppHandlesQuit(test *testing.T) { } } +func TestAppEnterWithNoThreadsDoesNothing(test *testing.T) { + store := state.NewThreadStore() + app := NewAppModel(store) + + enterKey := tea.KeyMsg{Type: tea.KeyEnter} + updated, _ := app.Update(enterKey) + appModel := updated.(AppModel) + + if appModel.FocusPane() != FocusPaneCanvas { + test.Errorf("expected canvas focus when no threads, got %d", appModel.FocusPane()) + } +} + +func TestAppCtrlBMOpensMenu(test *testing.T) { + store := state.NewThreadStore() + store.Add(appTestThreadID1, appTestTitleTest) + + 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() { + test.Error("expected menu to be visible") + } +} + +func TestAppMenuEscCloses(test *testing.T) { + store := state.NewThreadStore() + store.Add(appTestThreadID1, appTestTitleTest) + + 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) + + escKey := tea.KeyMsg{Type: tea.KeyEsc} + updated, _ = app.Update(escKey) + app = updated.(AppModel) + + if app.MenuVisible() { + test.Error("expected menu hidden after Esc") + } +} + +func TestAppCtrlBEscCancelsPrefix(test *testing.T) { + store := state.NewThreadStore() + store.Add(appTestThreadID1, appTestTitleTest) + + app := NewAppModel(store) + + ctrlB := tea.KeyMsg{Type: tea.KeyCtrlB} + updated, _ := app.Update(ctrlB) + app = updated.(AppModel) + + escKey := tea.KeyMsg{Type: tea.KeyEsc} + updated, _ = app.Update(escKey) + app = updated.(AppModel) + + if app.MenuVisible() { + test.Error("expected menu not visible after prefix cancel") + } +} + +func TestAppMenuNavigation(test *testing.T) { + store := state.NewThreadStore() + store.Add(appTestThreadID1, appTestTitleTest) + + 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) + + downKey := tea.KeyMsg{Type: tea.KeyDown} + updated, _ = app.Update(downKey) + app = updated.(AppModel) + + if app.menu.SelectedIndex() != appTestExpectedIndex1 { + test.Errorf("expected menu index 1, got %d", app.menu.SelectedIndex()) + } +} + func TestAppHelpToggle(test *testing.T) { store := state.NewThreadStore() app := NewAppModel(store) @@ -150,42 +250,25 @@ func TestAppNewThread(test *testing.T) { } } -func TestAppFocusPaneDefaultsToCanvas(test *testing.T) { - store := state.NewThreadStore() - app := NewAppModel(store) - - if app.FocusPane() != FocusPaneCanvas { - test.Errorf("expected FocusPaneCanvas, got %d", app.FocusPane()) - } -} - -func TestAppHasPinnedSessions(test *testing.T) { +func TestAppHandlesThreadCreatedMsg(test *testing.T) { store := state.NewThreadStore() - app := NewAppModel(store) + app := NewAppModel(store, WithInteractiveCommand(appTestCmdCat)) + app.width = appTestWidth + app.height = appTestHeight - if len(app.sessionPanel.PinnedSessions()) != 0 { - test.Errorf("expected 0 pinned sessions, got %d", len(app.sessionPanel.PinnedSessions())) - } -} + msg := ThreadCreatedMsg{ThreadID: appTestThreadNew, Title: appTestTitleNewThread} + updated, _ := app.Update(msg) + appModel := updated.(AppModel) + defer appModel.StopAllPTYSessions() -func TestHelpShowsPinKeybinding(test *testing.T) { - help := NewHelpModel() - view := help.View() - if !strings.Contains(view, "Space") { - test.Error("expected Space keybinding in help") - } - if !strings.Contains(view, "Ctrl+B x") { - test.Error("expected Ctrl+B x keybinding in help") + threads := store.All() + if len(threads) != 1 { + test.Fatalf(appTestExpected1Thread, len(threads)) } - if !strings.Contains(view, "Ctrl+B z") { - test.Error("expected Ctrl+B z keybinding in help") + if threads[0].ID != appTestThreadNew { + test.Errorf("expected thread t-new, got %s", threads[0].ID) } -} - -func TestHelpShowsKillKeybinding(test *testing.T) { - help := NewHelpModel() - view := help.View() - if !strings.Contains(view, "Kill") { - test.Error("expected Kill keybinding in help") + if appModel.FocusPane() != FocusPaneSession { + test.Errorf(appTestExpectSessionFocus, appModel.FocusPane()) } } diff --git a/internal/tui/bridge.go b/internal/tui/bridge.go deleted file mode 100644 index 0205bf8..0000000 --- a/internal/tui/bridge.go +++ /dev/null @@ -1,102 +0,0 @@ -package tui - -import ( - "encoding/json" - - tea "github.com/charmbracelet/bubbletea" - "github.com/robinojw/dj/internal/appserver" -) - -// ProtoEventToMsg converts a codex proto event into a Bubble Tea message. -func ProtoEventToMsg(event appserver.ProtoEvent) tea.Msg { - var header appserver.EventHeader - if err := json.Unmarshal(event.Msg, &header); err != nil { - return nil - } - - switch header.Type { - case appserver.EventSessionConfigured: - return decodeSessionConfigured(event.Msg) - case appserver.EventTaskStarted: - return TaskStartedMsg{} - case appserver.EventTaskComplete: - return decodeTaskComplete(event.Msg) - case appserver.EventAgentMessageDelta: - return decodeAgentDelta(event.Msg) - case appserver.EventAgentMessage: - return decodeAgentMessage(event.Msg) - case appserver.EventAgentReasonDelta: - return decodeReasoningDelta(event.Msg) - case appserver.EventExecApproval: - return decodeExecApproval(event) - case appserver.EventPatchApproval: - return decodePatchApproval(event) - } - return nil -} - -func decodeSessionConfigured(raw json.RawMessage) tea.Msg { - var config appserver.SessionConfigured - if err := json.Unmarshal(raw, &config); err != nil { - return nil - } - return SessionConfiguredMsg{ - SessionID: config.SessionID, - Model: config.Model, - } -} - -func decodeTaskComplete(raw json.RawMessage) tea.Msg { - var complete appserver.TaskComplete - if err := json.Unmarshal(raw, &complete); err != nil { - return nil - } - return TaskCompleteMsg{LastMessage: complete.LastAgentMessage} -} - -func decodeAgentDelta(raw json.RawMessage) tea.Msg { - var delta appserver.AgentDelta - if err := json.Unmarshal(raw, &delta); err != nil { - return nil - } - return AgentDeltaMsg{Delta: delta.Delta} -} - -func decodeAgentMessage(raw json.RawMessage) tea.Msg { - var msg appserver.AgentMessage - if err := json.Unmarshal(raw, &msg); err != nil { - return nil - } - return AgentMessageCompletedMsg{Message: msg.Message} -} - -func decodeReasoningDelta(raw json.RawMessage) tea.Msg { - var delta appserver.AgentDelta - if err := json.Unmarshal(raw, &delta); err != nil { - return nil - } - return AgentReasoningDeltaMsg{Delta: delta.Delta} -} - -func decodeExecApproval(event appserver.ProtoEvent) tea.Msg { - var req appserver.ExecCommandRequest - if err := json.Unmarshal(event.Msg, &req); err != nil { - return nil - } - return ExecApprovalRequestMsg{ - EventID: event.ID, - Command: req.Command, - Cwd: req.Cwd, - } -} - -func decodePatchApproval(event appserver.ProtoEvent) tea.Msg { - var req appserver.PatchApplyRequest - if err := json.Unmarshal(event.Msg, &req); err != nil { - return nil - } - return PatchApprovalRequestMsg{ - EventID: event.ID, - Patch: req.Patch, - } -} diff --git a/internal/tui/bridge_test.go b/internal/tui/bridge_test.go deleted file mode 100644 index f321fdf..0000000 --- a/internal/tui/bridge_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package tui - -import ( - "encoding/json" - "testing" - - "github.com/robinojw/dj/internal/appserver" -) - -const testRequestID = "req-1" - -func TestBridgeSessionConfigured(testing *testing.T) { - event := appserver.ProtoEvent{ - ID: "", - Msg: json.RawMessage(`{"type":"session_configured","session_id":"s-1","model":"o4-mini"}`), - } - msg := ProtoEventToMsg(event) - configured, ok := msg.(SessionConfiguredMsg) - if !ok { - testing.Fatalf("expected SessionConfiguredMsg, got %T", msg) - } - if configured.SessionID != "s-1" { - testing.Errorf("expected s-1, got %s", configured.SessionID) - } - if configured.Model != "o4-mini" { - testing.Errorf("expected o4-mini, got %s", configured.Model) - } -} - -func TestBridgeTaskStarted(testing *testing.T) { - event := appserver.ProtoEvent{ - Msg: json.RawMessage(`{"type":"task_started","model_context_window":200000}`), - } - msg := ProtoEventToMsg(event) - _, ok := msg.(TaskStartedMsg) - if !ok { - testing.Fatalf("expected TaskStartedMsg, got %T", msg) - } -} - -func TestBridgeAgentDelta(testing *testing.T) { - event := appserver.ProtoEvent{ - Msg: json.RawMessage(`{"type":"agent_message_delta","delta":"Hello"}`), - } - msg := ProtoEventToMsg(event) - delta, ok := msg.(AgentDeltaMsg) - if !ok { - testing.Fatalf("expected AgentDeltaMsg, got %T", msg) - } - if delta.Delta != "Hello" { - testing.Errorf("expected Hello, got %s", delta.Delta) - } -} - -func TestBridgeAgentMessage(testing *testing.T) { - event := appserver.ProtoEvent{ - Msg: json.RawMessage(`{"type":"agent_message","message":"Hello world"}`), - } - msg := ProtoEventToMsg(event) - completed, ok := msg.(AgentMessageCompletedMsg) - if !ok { - testing.Fatalf("expected AgentMessageCompletedMsg, got %T", msg) - } - if completed.Message != "Hello world" { - testing.Errorf("expected Hello world, got %s", completed.Message) - } -} - -func TestBridgeTaskComplete(testing *testing.T) { - event := appserver.ProtoEvent{ - Msg: json.RawMessage(`{"type":"task_complete","last_agent_message":"Done"}`), - } - msg := ProtoEventToMsg(event) - complete, ok := msg.(TaskCompleteMsg) - if !ok { - testing.Fatalf("expected TaskCompleteMsg, got %T", msg) - } - if complete.LastMessage != "Done" { - testing.Errorf("expected Done, got %s", complete.LastMessage) - } -} - -func TestBridgeExecApproval(testing *testing.T) { - event := appserver.ProtoEvent{ - ID: testRequestID, - Msg: json.RawMessage(`{"type":"exec_command_request","command":"ls","cwd":"/tmp"}`), - } - msg := ProtoEventToMsg(event) - approval, ok := msg.(ExecApprovalRequestMsg) - if !ok { - testing.Fatalf("expected ExecApprovalRequestMsg, got %T", msg) - } - if approval.EventID != testRequestID { - testing.Errorf("expected %s, got %s", testRequestID, approval.EventID) - } - if approval.Command != "ls" { - testing.Errorf("expected ls, got %s", approval.Command) - } -} - -func TestBridgeAgentReasoningDelta(testing *testing.T) { - event := appserver.ProtoEvent{ - Msg: json.RawMessage(`{"type":"agent_reasoning_delta","delta":"Let me think..."}`), - } - msg := ProtoEventToMsg(event) - reasoning, ok := msg.(AgentReasoningDeltaMsg) - if !ok { - testing.Fatalf("expected AgentReasoningDeltaMsg, got %T", msg) - } - if reasoning.Delta != "Let me think..." { - testing.Errorf("expected Let me think..., got %s", reasoning.Delta) - } -} - -func TestBridgeUnknownEventReturnsNil(testing *testing.T) { - event := appserver.ProtoEvent{ - Msg: json.RawMessage(`{"type":"unknown_event"}`), - } - msg := ProtoEventToMsg(event) - if msg != nil { - testing.Errorf("expected nil for unknown event, got %T", msg) - } -} - -func TestBridgeInvalidJSONReturnsNil(testing *testing.T) { - event := appserver.ProtoEvent{ - Msg: json.RawMessage(`not json`), - } - msg := ProtoEventToMsg(event) - if msg != nil { - testing.Errorf("expected nil for invalid JSON, got %T", msg) - } -} diff --git a/internal/tui/bridge_v2.go b/internal/tui/bridge_v2.go new file mode 100644 index 0000000..356c335 --- /dev/null +++ b/internal/tui/bridge_v2.go @@ -0,0 +1,31 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/appserver" +) + +// V2MessageToMsg routes a JSON-RPC message by method to the appropriate Bubble Tea message. +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) + case appserver.MethodCollabSpawnEnd: + return decodeCollabSpawnEnd(message.Params) + case appserver.MethodCollabCloseEnd: + return decodeCollabCloseEnd(message.Params) + } + return nil +} diff --git a/internal/tui/bridge_v2_approval.go b/internal/tui/bridge_v2_approval.go new file mode 100644 index 0000000..d0d7c12 --- /dev/null +++ b/internal/tui/bridge_v2_approval.go @@ -0,0 +1,59 @@ +package tui + +import ( + "encoding/json" + + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/appserver" +) + +func decodeV2ExecApproval(message appserver.JSONRPCMessage) tea.Msg { + var request appserver.CommandApprovalRequest + if err := json.Unmarshal(message.Params, &request); err != nil { + return nil + } + return V2ExecApprovalMsg{ + RequestID: message.ID, + ThreadID: request.ThreadID, + Command: request.Command.Command, + Cwd: request.Command.Cwd, + } +} + +func decodeV2FileApproval(message appserver.JSONRPCMessage) tea.Msg { + var request appserver.FileChangeApprovalRequest + if err := json.Unmarshal(message.Params, &request); err != nil { + return nil + } + return V2FileApprovalMsg{ + RequestID: message.ID, + ThreadID: request.ThreadID, + Patch: request.Patch, + } +} + +func decodeCollabSpawnEnd(raw json.RawMessage) tea.Msg { + var event appserver.CollabSpawnEndEvent + if err := json.Unmarshal(raw, &event); err != nil { + return nil + } + return CollabSpawnMsg{ + SenderThreadID: event.SenderThreadID, + NewThreadID: event.NewThreadID, + NewAgentNickname: event.NewAgentNickname, + NewAgentRole: event.NewAgentRole, + Status: event.Status, + } +} + +func decodeCollabCloseEnd(raw json.RawMessage) tea.Msg { + var event appserver.CollabCloseEndEvent + if err := json.Unmarshal(raw, &event); err != nil { + return nil + } + return CollabCloseMsg{ + SenderThreadID: event.SenderThreadID, + ReceiverThreadID: event.ReceiverThreadID, + Status: event.Status, + } +} diff --git a/internal/tui/bridge_v2_decode.go b/internal/tui/bridge_v2_decode.go new file mode 100644 index 0000000..35774c6 --- /dev/null +++ b/internal/tui/bridge_v2_decode.go @@ -0,0 +1,69 @@ +package tui + +import ( + "encoding/json" + + tea "github.com/charmbracelet/bubbletea" + "github.com/robinojw/dj/internal/appserver" +) + +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, + } +} + +func decodeTurnStarted(raw json.RawMessage) tea.Msg { + var notification appserver.TurnStartedNotification + if err := json.Unmarshal(raw, ¬ification); err != nil { + return nil + } + return TurnStartedMsg{ + ThreadID: notification.ThreadID, + TurnID: notification.Turn.ID, + } +} + +func decodeTurnCompleted(raw json.RawMessage) tea.Msg { + var notification appserver.TurnCompletedNotification + if err := json.Unmarshal(raw, ¬ification); err != nil { + return nil + } + return TurnCompletedMsg{ + ThreadID: notification.ThreadID, + TurnID: notification.Turn.ID, + } +} + +func decodeV2AgentDelta(raw json.RawMessage) tea.Msg { + var notification appserver.AgentMessageDeltaNotification + if err := json.Unmarshal(raw, ¬ification); err != nil { + return nil + } + return V2AgentDeltaMsg{ + ThreadID: notification.ThreadID, + Delta: notification.Delta, + } +} + +func decodeThreadStatusChanged(raw json.RawMessage) tea.Msg { + var notification appserver.ThreadStatusChangedNotification + if err := json.Unmarshal(raw, ¬ification); err != nil { + return nil + } + return ThreadStatusChangedMsg{ + ThreadID: notification.ThreadID, + Status: notification.Status, + } +} diff --git a/internal/tui/bridge_v2_test.go b/internal/tui/bridge_v2_test.go new file mode 100644 index 0000000..87d9761 --- /dev/null +++ b/internal/tui/bridge_v2_test.go @@ -0,0 +1,87 @@ +package tui + +import ( + "encoding/json" + "testing" + + "github.com/robinojw/dj/internal/appserver" +) + +const ( + bridgeV2ExpectedTypeFormat = "expected %T, got %T" + bridgeV2ThreadID = "t-1" + bridgeV2ExpectedThreadFmt = "expected t-1, got %s" +) + +func TestBridgeV2ThreadStarted(test *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 { + test.Fatalf(bridgeV2ExpectedTypeFormat, ThreadStartedMsg{}, msg) + } + if started.ThreadID != bridgeV2ThreadID { + test.Errorf(bridgeV2ExpectedThreadFmt, started.ThreadID) + } +} + +func TestBridgeV2SubAgentThread(test *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 { + test.Fatalf(bridgeV2ExpectedTypeFormat, ThreadStartedMsg{}, msg) + } + if started.ParentID != bridgeV2ThreadID { + test.Errorf(bridgeV2ExpectedThreadFmt, started.ParentID) + } + if started.AgentRole != "researcher" { + test.Errorf("expected researcher, got %s", started.AgentRole) + } +} + +func TestBridgeV2TurnStarted(test *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 { + test.Fatalf(bridgeV2ExpectedTypeFormat, TurnStartedMsg{}, msg) + } + if turn.ThreadID != bridgeV2ThreadID { + test.Errorf(bridgeV2ExpectedThreadFmt, turn.ThreadID) + } +} + +func TestBridgeV2AgentDelta(test *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 { + test.Fatalf(bridgeV2ExpectedTypeFormat, V2AgentDeltaMsg{}, msg) + } + if delta.Delta != "hello" { + test.Errorf("expected hello, got %s", delta.Delta) + } +} + +func TestBridgeV2UnknownMethodReturnsNil(test *testing.T) { + message := appserver.JSONRPCMessage{ + Method: "some/unknown/method", + } + msg := V2MessageToMsg(message) + if msg != nil { + test.Errorf("expected nil for unknown method, got %T", msg) + } +} diff --git a/internal/tui/canvas.go b/internal/tui/canvas.go index 9805d3e..db9842e 100644 --- a/internal/tui/canvas.go +++ b/internal/tui/canvas.go @@ -42,7 +42,7 @@ func (canvas *CanvasModel) SelectedIndex() int { } func (canvas *CanvasModel) SelectedThreadID() string { - threads := canvas.store.All() + threads := canvas.store.TreeOrder() if len(threads) == 0 { return "" } @@ -50,7 +50,7 @@ func (canvas *CanvasModel) SelectedThreadID() string { } func (canvas *CanvasModel) SetSelected(index int) { - threads := canvas.store.All() + threads := canvas.store.TreeOrder() isValidIndex := index >= 0 && index < len(threads) if isValidIndex { canvas.selected = index @@ -70,7 +70,7 @@ func (canvas *CanvasModel) ClampSelected() { } func (canvas *CanvasModel) MoveRight() { - threads := canvas.store.All() + threads := canvas.store.TreeOrder() if canvas.selected < len(threads)-1 { canvas.selected++ } @@ -83,7 +83,7 @@ func (canvas *CanvasModel) MoveLeft() { } func (canvas *CanvasModel) MoveDown() { - threads := canvas.store.All() + threads := canvas.store.TreeOrder() next := canvas.selected + canvasColumns if next < len(threads) { canvas.selected = next @@ -107,7 +107,7 @@ func (canvas *CanvasModel) centerContent(content string) string { } func (canvas *CanvasModel) View() string { - threads := canvas.store.All() + threads := canvas.store.TreeOrder() if len(threads) == 0 { return canvas.renderEmpty() } diff --git a/internal/tui/canvas_edges.go b/internal/tui/canvas_edges.go new file mode 100644 index 0000000..61b1a71 --- /dev/null +++ b/internal/tui/canvas_edges.go @@ -0,0 +1,107 @@ +package tui + +import "strings" + +const ( + edgeVertical = "│" + edgeHorizontal = "─" + edgeTeeRight = "├" + edgeElbow = "└" + edgeTeeDown = "┬" + edgeCornerRight = "┐" + cardCenterDiv = 2 +) + +func renderConnectorRow(parentCol int, childCols []int, cardWidth int, gap int) string { + if len(childCols) == 0 { + return "" + } + + cellWidth := cardWidth + gap + parentCenter := parentCol*cellWidth + cardWidth/cardCenterDiv + totalWidth := computeConnectorWidth(childCols, cellWidth, cardWidth) + + return buildConnectorLine(parentCenter, childCols, cellWidth, cardWidth, totalWidth) +} + +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 := buildChildCenterSet(childCols, cellWidth, cardWidth) + spanStart, spanEnd := computeSpan(parentCenter, childCenters, totalWidth) + + topLine := strings.Repeat(" ", parentCenter) + edgeVertical + bottomLine := renderBottomLine(parentCenter, childCenters, spanStart, spanEnd) + + return topLine + "\n" + bottomLine +} + +func buildChildCenterSet(childCols []int, cellWidth int, cardWidth int) map[int]bool { + childCenters := make(map[int]bool, len(childCols)) + for _, col := range childCols { + center := col*cellWidth + cardWidth/cardCenterDiv + childCenters[center] = true + } + return childCenters +} + +func computeSpan(parentCenter int, childCenters map[int]bool, totalWidth int) (int, int) { + spanStart := totalWidth + spanEnd := 0 + for center := range childCenters { + if center < spanStart { + spanStart = center + } + if center > spanEnd { + spanEnd = center + } + } + if parentCenter < spanStart { + spanStart = parentCenter + } + if parentCenter > spanEnd { + spanEnd = parentCenter + } + return spanStart, spanEnd +} + +func renderBottomLine(parentCenter int, childCenters map[int]bool, spanStart int, spanEnd int) string { + var builder strings.Builder + for position := 0; position <= spanEnd; position++ { + isChild := childCenters[position] + isParent := position == parentCenter + isInSpan := position >= spanStart && position <= spanEnd + + builder.WriteString(resolveConnectorChar(isChild, isParent, isInSpan)) + } + return builder.String() +} + +func resolveConnectorChar(isChild bool, isParent bool, inSpan bool) string { + isParentAndChild := isParent && isChild + if isParentAndChild { + return edgeTeeDown + } + if isParent { + return edgeTeeDown + } + isChildInSpan := isChild && inSpan + if isChildInSpan { + return edgeElbow + } + if isChild { + return edgeVertical + } + if inSpan { + return edgeHorizontal + } + return " " +} diff --git a/internal/tui/canvas_edges_test.go b/internal/tui/canvas_edges_test.go new file mode 100644 index 0000000..68d4ed2 --- /dev/null +++ b/internal/tui/canvas_edges_test.go @@ -0,0 +1,40 @@ +package tui + +import ( + "strings" + "testing" +) + +const ( + edgeTestCardWidth = 20 + edgeTestGap = 2 + edgeTestSecondColumn = 2 +) + +func TestRenderConnectorSimple(test *testing.T) { + parentCol := 0 + childCols := []int{0} + connector := renderConnectorRow(parentCol, childCols, edgeTestCardWidth, edgeTestGap) + if !strings.Contains(connector, edgeVertical) { + test.Error("expected vertical connector") + } +} + +func TestRenderConnectorBranching(test *testing.T) { + parentCol := 0 + childCols := []int{0, edgeTestSecondColumn} + connector := renderConnectorRow(parentCol, childCols, edgeTestCardWidth, edgeTestGap) + hasBranch := strings.Contains(connector, edgeTeeDown) || strings.Contains(connector, edgeHorizontal) + if !hasBranch { + test.Error("expected branching connector with horizontal lines") + } +} + +func TestRenderConnectorNoChildren(test *testing.T) { + parentCol := 0 + childCols := []int{} + connector := renderConnectorRow(parentCol, childCols, edgeTestCardWidth, edgeTestGap) + if connector != "" { + test.Error("expected empty string for no children") + } +} diff --git a/internal/tui/canvas_test.go b/internal/tui/canvas_test.go index 9853604..ff34b86 100644 --- a/internal/tui/canvas_test.go +++ b/internal/tui/canvas_test.go @@ -8,21 +8,28 @@ import ( ) const ( - testThreadID1 = "t-1" - testThreadID2 = "t-2" - testThreadID3 = "t-3" - testThreadTitle1 = "First" - testThreadTitle2 = "Second" - testThreadTitle3 = "Third" - testCanvasWidth = 120 - testCanvasHeight = 30 + canvasTestID1 = "t-1" + canvasTestID2 = "t-2" + canvasTestID3 = "t-3" + canvasTestFirst = "First" + canvasTestSecond = "Second" + canvasTestThird = "Third" + canvasTestWidth = 120 + canvasTestHeight = 30 + canvasTestRootID = "root" + canvasTestChild1ID = "child-1" + canvasTestChild2ID = "child-2" + canvasTestTitleRoot = "Root" + canvasTestTitleChild1 = "Child 1" + canvasTestTitleChild2 = "Child 2" + canvasTestTitleOnly = "Only" ) func TestCanvasNavigation(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testThreadTitle1) - store.Add(testThreadID2, testThreadTitle2) - store.Add(testThreadID3, testThreadTitle3) + store.Add(canvasTestID1, canvasTestFirst) + store.Add(canvasTestID2, canvasTestSecond) + store.Add(canvasTestID3, canvasTestThird) canvas := NewCanvasModel(store) @@ -43,8 +50,8 @@ func TestCanvasNavigation(test *testing.T) { func TestCanvasNavigationBounds(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testThreadTitle1) - store.Add(testThreadID2, testThreadTitle2) + store.Add(canvasTestID1, canvasTestFirst) + store.Add(canvasTestID2, canvasTestSecond) canvas := NewCanvasModel(store) @@ -62,15 +69,15 @@ func TestCanvasNavigationBounds(test *testing.T) { func TestCanvasSelectedThreadID(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testThreadTitle1) - store.Add(testThreadID2, testThreadTitle2) + store.Add(canvasTestID1, canvasTestFirst) + store.Add(canvasTestID2, canvasTestSecond) canvas := NewCanvasModel(store) canvas.MoveRight() id := canvas.SelectedThreadID() - if id != testThreadID2 { - test.Errorf("expected %s, got %s", testThreadID2, id) + if id != canvasTestID2 { + test.Errorf("expected %s, got %s", canvasTestID2, id) } } @@ -90,8 +97,8 @@ func TestCanvasEmptyStore(test *testing.T) { func TestCanvasClampSelectedAfterDeletion(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testThreadTitle1) - store.Add(testThreadID2, testThreadTitle2) + store.Add(canvasTestID1, canvasTestFirst) + store.Add(canvasTestID2, canvasTestSecond) canvas := NewCanvasModel(store) canvas.MoveRight() @@ -100,7 +107,7 @@ func TestCanvasClampSelectedAfterDeletion(test *testing.T) { test.Fatalf("expected index 1, got %d", canvas.SelectedIndex()) } - store.Delete(testThreadID2) + store.Delete(canvasTestID2) canvas.ClampSelected() if canvas.SelectedIndex() != 0 { @@ -110,10 +117,10 @@ func TestCanvasClampSelectedAfterDeletion(test *testing.T) { func TestCanvasClampSelectedEmptyStore(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, "Only") + store.Add(canvasTestID1, canvasTestTitleOnly) canvas := NewCanvasModel(store) - store.Delete(testThreadID1) + store.Delete(canvasTestID1) canvas.ClampSelected() if canvas.SelectedIndex() != 0 { @@ -123,15 +130,41 @@ func TestCanvasClampSelectedEmptyStore(test *testing.T) { func TestCanvasViewWithDimensions(test *testing.T) { store := state.NewThreadStore() - store.Add(testThreadID1, testThreadTitle1) - store.Add(testThreadID2, testThreadTitle2) - store.Add(testThreadID3, testThreadTitle3) + store.Add(canvasTestID1, canvasTestFirst) + store.Add(canvasTestID2, canvasTestSecond) + store.Add(canvasTestID3, canvasTestThird) canvas := NewCanvasModel(store) - canvas.SetDimensions(testCanvasWidth, testCanvasHeight) + canvas.SetDimensions(canvasTestWidth, canvasTestHeight) output := canvas.View() - if !strings.Contains(output, testThreadTitle1) { - test.Errorf("expected %s in output:\n%s", testThreadTitle1, output) + if !strings.Contains(output, canvasTestFirst) { + test.Errorf("expected First in output:\n%s", output) + } +} + +func TestCanvasTreeOrder(test *testing.T) { + store := state.NewThreadStore() + store.Add(canvasTestRootID, canvasTestTitleRoot) + store.AddWithParent(canvasTestChild1ID, canvasTestTitleChild1, canvasTestRootID) + store.AddWithParent(canvasTestChild2ID, canvasTestTitleChild2, canvasTestRootID) + + canvas := NewCanvasModel(store) + canvas.SetDimensions(canvasTestWidth, canvasTestHeight) + + view := canvas.View() + rootIndex := strings.Index(view, canvasTestTitleRoot) + child1Index := strings.Index(view, canvasTestTitleChild1) + child2Index := strings.Index(view, canvasTestTitleChild2) + + allVisible := rootIndex != -1 && child1Index != -1 && child2Index != -1 + if !allVisible { + test.Fatal("expected all threads to appear in view") + } + if rootIndex > child1Index { + test.Error("root should appear before child-1") + } + if child1Index > child2Index { + test.Error("child-1 should appear before child-2") } } diff --git a/internal/tui/card.go b/internal/tui/card.go index 2f0efbe..a85be0c 100644 --- a/internal/tui/card.go +++ b/internal/tui/card.go @@ -30,6 +30,8 @@ var ( ) const pinnedIndicator = " ✓" +const subAgentPrefix = "↳ " +const roleIndent = " " type CardModel struct { thread *state.ThreadState @@ -61,6 +63,33 @@ func (card *CardModel) SetSize(width int, height int) { } func (card CardModel) View() string { + title := card.buildTitle() + statusLine := card.buildStatusLine() + 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 +} + +func (card CardModel) buildStatusLine() string { statusColor, exists := statusColors[card.thread.Status] if !exists { statusColor = defaultStatusColor @@ -72,20 +101,24 @@ func (card CardModel) View() string { secondLine = card.thread.Activity } - styledSecondLine := lipgloss.NewStyle(). + return lipgloss.NewStyle(). Foreground(statusColor). Render(truncate(secondLine, card.width-cardBorderPadding)) +} - titleMaxLen := card.width - cardBorderPadding - if card.pinned { - titleMaxLen -= len(pinnedIndicator) +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(roleIndent + card.thread.AgentRole) + return fmt.Sprintf("%s\n%s\n%s", title, roleLine, statusLine) } - title := truncate(card.thread.Title, titleMaxLen) - if card.pinned { - title += pinnedIndicator - } - content := fmt.Sprintf("%s\n%s", title, styledSecondLine) + return fmt.Sprintf("%s\n%s", title, statusLine) +} +func (card CardModel) buildBorderStyle() lipgloss.Style { style := lipgloss.NewStyle(). Width(card.width). Height(card.height). @@ -97,8 +130,7 @@ func (card CardModel) View() string { Border(lipgloss.DoubleBorder()). BorderForeground(lipgloss.Color("39")) } - - return style.Render(content) + return style } func truncate(text string, maxLen int) string { diff --git a/internal/tui/card_test.go b/internal/tui/card_test.go index 7de5410..9aa268a 100644 --- a/internal/tui/card_test.go +++ b/internal/tui/card_test.go @@ -8,13 +8,19 @@ import ( ) const ( - testThreadID = "t-1" - testThreadTitle = "Test" - testBuildTitle = "Build web app" - testActivity = "Running: git status" - testLongActivity = "This is a very long activity string that should definitely be truncated when rendered on a small card" - testCardWidth = 50 - testCardHeight = 10 + testThreadID = "t-1" + testThreadTitle = "Test" + testBuildTitle = "Build web app" + testActivity = "Running: git status" + testLongActivity = "This is a very long activity string that should definitely be truncated when rendered on a small card" + testCardWidth = 50 + testCardHeight = 10 + testParentThreadID = "t-0" + testScoutTitle = "Scout" + testResearcherRole = "researcher" + testSubCardWidth = 30 + testSubCardHeight = 6 + testDepthArrow = "\u21b3" ) func TestCardRenderShowsTitle(testing *testing.T) { @@ -119,3 +125,43 @@ func TestCardPinnedShowsIndicator(testing *testing.T) { testing.Errorf("expected pinned indicator in output, got:\n%s", output) } } + +func TestSubAgentCardShowsRole(test *testing.T) { + thread := state.NewThreadState(testThreadID, testScoutTitle) + thread.ParentID = testParentThreadID + thread.AgentRole = testResearcherRole + + card := NewCardModel(thread, false, false) + card.SetSize(testSubCardWidth, testSubCardHeight) + view := card.View() + + if !strings.Contains(view, testResearcherRole) { + test.Error("expected agent role in card view") + } +} + +func TestSubAgentCardShowsDepthPrefix(test *testing.T) { + thread := state.NewThreadState(testThreadID, testScoutTitle) + thread.ParentID = testParentThreadID + thread.Depth = 1 + + card := NewCardModel(thread, false, false) + card.SetSize(testSubCardWidth, testSubCardHeight) + view := card.View() + + if !strings.Contains(view, testDepthArrow) { + test.Error("expected depth prefix in sub-agent card") + } +} + +func TestRootCardNoDepthPrefix(test *testing.T) { + thread := state.NewThreadState(testParentThreadID, "Root Session") + + card := NewCardModel(thread, false, false) + card.SetSize(testSubCardWidth, testSubCardHeight) + view := card.View() + + if strings.Contains(view, testDepthArrow) { + test.Error("root card should not have depth prefix") + } +} diff --git a/internal/tui/integration_test.go b/internal/tui/integration_test.go index 6bdd3aa..b2bfaa5 100644 --- a/internal/tui/integration_test.go +++ b/internal/tui/integration_test.go @@ -11,37 +11,39 @@ import ( "github.com/robinojw/dj/internal/state" ) -func TestIntegrationEndToEnd(t *testing.T) { +const integrationTimeout = 15 * time.Second + +func TestIntegrationEndToEnd(test *testing.T) { client := appserver.NewClient("codex", "proto") - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), integrationTimeout) defer cancel() if err := client.Start(ctx); err != nil { - t.Fatalf("Start failed: %v", err) + test.Fatalf("Start failed: %v", err) } defer client.Stop() store := state.NewThreadStore() - events := make(chan SessionConfiguredMsg, 1) + events := make(chan ThreadStartedMsg, 1) - go client.ReadLoop(func(event appserver.ProtoEvent) { - msg := ProtoEventToMsg(event) - if configured, ok := msg.(SessionConfiguredMsg); ok { - store.Add(configured.SessionID, configured.Model) - events <- configured + go client.ReadLoop(func(message appserver.JSONRPCMessage) { + msg := V2MessageToMsg(message) + if started, ok := msg.(ThreadStartedMsg); ok { + store.Add(started.ThreadID, started.ThreadID) + events <- started } }) select { - case configured := <-events: - t.Logf("Connected: session %s, model %s", configured.SessionID, configured.Model) + case started := <-events: + test.Logf("Connected: thread %s started", started.ThreadID) case <-ctx.Done(): - t.Fatal("timeout waiting for session_configured") + test.Fatal("timeout waiting for thread_started") } threads := store.All() if len(threads) != 1 { - t.Fatalf("expected 1 thread, got %d", len(threads)) + test.Fatalf("expected 1 thread, got %d", len(threads)) } } diff --git a/internal/tui/msgs.go b/internal/tui/msgs.go index 1affc03..5e15901 100644 --- a/internal/tui/msgs.go +++ b/internal/tui/msgs.go @@ -1,39 +1,5 @@ package tui -type SessionConfiguredMsg struct { - SessionID string - Model string -} - -type TaskStartedMsg struct{} - -type TaskCompleteMsg struct { - LastMessage string -} - -type AgentDeltaMsg struct { - Delta string -} - -type AgentMessageCompletedMsg struct { - Message string -} - -type AgentReasoningDeltaMsg struct { - Delta string -} - -type ExecApprovalRequestMsg struct { - EventID string - Command string - Cwd string -} - -type PatchApprovalRequestMsg struct { - EventID string - Patch string -} - type ThreadCreatedMsg struct { ThreadID string Title string diff --git a/internal/tui/msgs_test.go b/internal/tui/msgs_test.go index a6e1197..14602c7 100644 --- a/internal/tui/msgs_test.go +++ b/internal/tui/msgs_test.go @@ -5,54 +5,44 @@ import ( "testing" ) -func TestMsgTypes(t *testing.T) { - configuredMsg := SessionConfiguredMsg{ - SessionID: "s-1", - Model: "o4-mini", - } - if configuredMsg.SessionID != "s-1" { - t.Errorf("expected s-1, got %s", configuredMsg.SessionID) - } - - deltaMsg := AgentDeltaMsg{Delta: "hello"} - if deltaMsg.Delta != "hello" { - t.Errorf("expected hello, got %s", deltaMsg.Delta) - } - - completeMsg := TaskCompleteMsg{LastMessage: "done"} - if completeMsg.LastMessage != "done" { - t.Errorf("expected done, got %s", completeMsg.LastMessage) - } +const ( + msgsTestError = "test error" + msgsTestThreadID1 = "t-1" + msgsTestThreadID2 = "t-2" + msgsTestExpectedFmt = "expected t-1, got %s" + msgsTestFocusIndex = 2 +) - errorMsg := AppServerErrorMsg{Err: fmt.Errorf("test error")} - if errorMsg.Error() != "test error" { - t.Errorf("expected test error, got %s", errorMsg.Error()) +func TestMsgTypes(test *testing.T) { + errorMsg := AppServerErrorMsg{Err: fmt.Errorf("%s", msgsTestError)} + if errorMsg.Error() != msgsTestError { + test.Errorf("expected test error, got %s", errorMsg.Error()) } - createdMsg := ThreadCreatedMsg{ThreadID: "t-1", Title: "Test"} - if createdMsg.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", createdMsg.ThreadID) + createdMsg := ThreadCreatedMsg{ThreadID: msgsTestThreadID1, Title: "Test"} + if createdMsg.ThreadID != msgsTestThreadID1 { + test.Errorf(msgsTestExpectedFmt, createdMsg.ThreadID) } } -func TestPinUnpinMessages(t *testing.T) { - pinMsg := PinSessionMsg{ThreadID: "t-1"} - if pinMsg.ThreadID != "t-1" { - t.Errorf("expected t-1, got %s", pinMsg.ThreadID) +func TestPinUnpinMessages(test *testing.T) { + pinMsg := PinSessionMsg{ThreadID: msgsTestThreadID1} + if pinMsg.ThreadID != msgsTestThreadID1 { + test.Errorf(msgsTestExpectedFmt, pinMsg.ThreadID) } - unpinMsg := UnpinSessionMsg{ThreadID: "t-2"} - if unpinMsg.ThreadID != "t-2" { - t.Errorf("expected t-2, got %s", unpinMsg.ThreadID) + unpinMsg := UnpinSessionMsg{ThreadID: msgsTestThreadID2} + if unpinMsg.ThreadID != msgsTestThreadID2 { + test.Errorf("expected t-2, got %s", unpinMsg.ThreadID) } - focusMsg := FocusSessionPaneMsg{Index: 2} - if focusMsg.Index != 2 { - t.Errorf("expected 2, got %d", focusMsg.Index) + focusMsg := FocusSessionPaneMsg{Index: msgsTestFocusIndex} + if focusMsg.Index != msgsTestFocusIndex { + test.Errorf("expected 2, got %d", focusMsg.Index) } switchMsg := SwitchPaneFocusMsg{Pane: FocusPaneSession} if switchMsg.Pane != FocusPaneSession { - t.Errorf("expected FocusPaneSession, got %d", switchMsg.Pane) + test.Errorf("expected FocusPaneSession, got %d", switchMsg.Pane) } } diff --git a/internal/tui/msgs_v2.go b/internal/tui/msgs_v2.go new file mode 100644 index 0000000..1cbe505 --- /dev/null +++ b/internal/tui/msgs_v2.go @@ -0,0 +1,73 @@ +package tui + +// ThreadStartedMsg is emitted when a new thread is created via v2 protocol. +type ThreadStartedMsg struct { + ThreadID string + Status string + SourceType string + ParentID string + Depth int + AgentNickname string + AgentRole string +} + +// ThreadStatusChangedMsg is emitted when a thread's status changes. +type ThreadStatusChangedMsg struct { + ThreadID string + Status string +} + +// TurnStartedMsg is emitted when a turn begins in a thread. +type TurnStartedMsg struct { + ThreadID string + TurnID string +} + +// TurnCompletedMsg is emitted when a turn finishes in a thread. +type TurnCompletedMsg struct { + ThreadID string + TurnID string +} + +// V2AgentDeltaMsg is a streaming text delta scoped to a thread. +type V2AgentDeltaMsg struct { + ThreadID string + Delta string +} + +// V2ExecApprovalMsg is a v2 command execution approval request. +type V2ExecApprovalMsg struct { + RequestID string + ThreadID string + Command string + Cwd string +} + +// V2FileApprovalMsg is a v2 file change approval request. +type V2FileApprovalMsg struct { + RequestID string + ThreadID string + Patch string +} + +// CollabSpawnMsg is emitted when a sub-agent is spawned. +type CollabSpawnMsg struct { + SenderThreadID string + NewThreadID string + NewAgentNickname string + NewAgentRole string + Status string +} + +// CollabCloseMsg is emitted when a sub-agent is closed. +type CollabCloseMsg struct { + SenderThreadID string + ReceiverThreadID string + Status string +} + +// CollabStatusUpdateMsg is emitted for general collab status changes. +type CollabStatusUpdateMsg struct { + ThreadID string + Status string +}