diff --git a/docs/plans/2026-03-18-session-card-activity-design.md b/docs/plans/2026-03-18-session-card-activity-design.md new file mode 100644 index 0000000..f3d2159 --- /dev/null +++ b/docs/plans/2026-03-18-session-card-activity-design.md @@ -0,0 +1,50 @@ +# Session Card Activity Indicators + +## Problem + +Session cards show only a static status word (idle/active/completed/error). Users cannot tell what the CLI is actually doing — whether it's thinking, running a command, applying a patch, or streaming a response. + +## Design + +An `Activity` field on `ThreadState` tracks the current action for each session. The card renders activity instead of the status word when present. When there is no activity, the card falls back to the existing status display. + +## Activity Mapping + +| Protocol Event | Activity Text | Behavior | +|---|---|---| +| `task_started` | `Thinking...` | Set on task start | +| `agent_reasoning_delta` | `Thinking...` | Set during reasoning | +| `agent_message_delta` | Truncated snippet of streaming text | Updated with each delta | +| `exec_command_request` | `Running: ` | Truncated to card width | +| `patch_apply_request` | `Applying patch...` | Set on patch request | +| `agent_message` (completed) | Clears activity | Falls back to status | +| `task_complete` | Clears activity | Falls back to status | + +## Card Rendering + +Activity replaces the status line when present: + +``` +Active with activity: +┌──────────────────────────────┐ +│ o3-mini │ +│ Running: git status │ +└──────────────────────────────┘ + +Idle (no activity): +┌──────────────────────────────┐ +│ o3-mini │ +│ idle │ +└──────────────────────────────┘ +``` + +Activity text uses the same color as the current status. + +## Changes + +- `state/thread.go`: Add `Activity` field, `SetActivity()`, `ClearActivity()` methods +- `state/store.go`: Add `UpdateActivity(id, activity)` method (thread-safe) +- `tui/card.go`: Prefer `thread.Activity` over `thread.Status` for card second line +- `tui/app_proto.go`: Set activity in event handlers +- `tui/bridge.go`: Handle `agent_reasoning_delta` events +- `tui/msgs.go`: Add `AgentReasoningDeltaMsg` type diff --git a/docs/plans/2026-03-18-session-card-activity-plan.md b/docs/plans/2026-03-18-session-card-activity-plan.md new file mode 100644 index 0000000..7353aba --- /dev/null +++ b/docs/plans/2026-03-18-session-card-activity-plan.md @@ -0,0 +1,551 @@ +# Session Card Activity Indicators — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Show real-time activity on session cards (e.g. "Running: git status", "Thinking...", streaming text snippets) by adding an `Activity` field to `ThreadState` and rendering it on cards in place of the status word. + +**Architecture:** Protocol event handlers set an `Activity` string on the thread via the store. The card's `View()` method checks `Activity` first — if non-empty, renders it with status color instead of the status word. Activity is cleared on `agent_message` (completed) and `task_complete`. + +**Tech Stack:** Go, Bubble Tea, Lipgloss, standard `testing` package. + +--- + +### Task 1: Add Activity field and methods to ThreadState + +**Files:** +- Modify: `internal/state/thread.go` +- Modify: `internal/state/thread_test.go` + +**Step 1: Write the failing tests** + +Add to `internal/state/thread_test.go`: + +```go +func TestThreadStateSetActivity(t *testing.T) { + thread := NewThreadState("t-1", "Test") + thread.SetActivity("Running: git status") + + if thread.Activity != "Running: git status" { + t.Errorf("expected Running: git status, got %s", thread.Activity) + } +} + +func TestThreadStateClearActivity(t *testing.T) { + thread := NewThreadState("t-1", "Test") + thread.SetActivity("Thinking...") + thread.ClearActivity() + + if thread.Activity != "" { + t.Errorf("expected empty activity, got %s", thread.Activity) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/state -run TestThreadState(Set|Clear)Activity -v` +Expected: FAIL — `thread.Activity undefined`, `thread.SetActivity undefined` + +**Step 3: Write minimal implementation** + +Add `Activity` field to `ThreadState` struct and two methods in `internal/state/thread.go`: + +```go +type ThreadState struct { + ID string + Title string + Status string + Activity string + ParentID string + Messages []ChatMessage + CommandOutput map[string]string +} + +func (threadState *ThreadState) SetActivity(activity string) { + threadState.Activity = activity +} + +func (threadState *ThreadState) ClearActivity() { + threadState.Activity = "" +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/state -run TestThreadState(Set|Clear)Activity -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/state/thread.go internal/state/thread_test.go +git commit -m "feat: add Activity field to ThreadState" +``` + +--- + +### Task 2: Add UpdateActivity to ThreadStore + +**Files:** +- Modify: `internal/state/store.go` +- Modify: `internal/state/store_test.go` + +**Step 1: Write the failing tests** + +Add to `internal/state/store_test.go`: + +```go +func TestStoreUpdateActivity(t *testing.T) { + store := NewThreadStore() + store.Add("t-1", "Test") + store.UpdateActivity("t-1", "Thinking...") + + thread, _ := store.Get("t-1") + if thread.Activity != "Thinking..." { + t.Errorf("expected Thinking..., got %s", thread.Activity) + } +} + +func TestStoreUpdateActivityMissing(t *testing.T) { + store := NewThreadStore() + store.UpdateActivity("missing", "Thinking...") +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/state -run TestStoreUpdateActivity -v` +Expected: FAIL — `store.UpdateActivity undefined` + +**Step 3: Write minimal implementation** + +Add to `internal/state/store.go`: + +```go +func (store *ThreadStore) UpdateActivity(id string, activity string) { + store.mu.Lock() + defer store.mu.Unlock() + + thread, exists := store.threads[id] + if !exists { + return + } + thread.Activity = activity +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/state -run TestStoreUpdateActivity -v` +Expected: PASS + +**Step 5: Run full state package tests** + +Run: `go test ./internal/state -v` +Expected: All PASS + +**Step 6: Commit** + +```bash +git add internal/state/store.go internal/state/store_test.go +git commit -m "feat: add UpdateActivity to ThreadStore" +``` + +--- + +### Task 3: Render activity on session cards + +**Files:** +- Modify: `internal/tui/card.go` +- Modify: `internal/tui/card_test.go` + +**Step 1: Write the failing tests** + +Add to `internal/tui/card_test.go`: + +```go +func TestCardRenderShowsActivity(t *testing.T) { + thread := state.NewThreadState("t-1", "o3-mini") + thread.Status = state.StatusActive + thread.Activity = "Running: git status" + + card := NewCardModel(thread, false) + output := card.View() + + if !strings.Contains(output, "Running: git status") { + t.Errorf("expected activity in output, got:\n%s", output) + } +} + +func TestCardRenderFallsBackToStatus(t *testing.T) { + thread := state.NewThreadState("t-1", "o3-mini") + thread.Status = state.StatusIdle + + card := NewCardModel(thread, false) + output := card.View() + + hasActivity := strings.Contains(output, "Running") + if hasActivity { + t.Errorf("expected no activity when idle, got:\n%s", output) + } + if !strings.Contains(output, "idle") { + t.Errorf("expected status fallback, got:\n%s", output) + } +} + +func TestCardRenderActivityTruncated(t *testing.T) { + thread := state.NewThreadState("t-1", "o3-mini") + thread.Status = state.StatusActive + thread.Activity = "Running: npm install --save-dev @types/react @types/react-dom" + + card := NewCardModel(thread, false) + card.SetSize(minCardWidth, minCardHeight) + output := card.View() + + if !strings.Contains(output, "...") { + t.Errorf("expected truncated activity, got:\n%s", output) + } +} +``` + +**Step 2: Run tests to verify they fail** + +Run: `go test ./internal/tui -run TestCardRender(ShowsActivity|FallsBack|ActivityTruncated) -v` +Expected: FAIL — activity text not present in output + +**Step 3: Write minimal implementation** + +Modify `card.View()` in `internal/tui/card.go`. Replace the `statusLine` logic with an activity-aware check: + +```go +func (card CardModel) View() string { + statusColor, exists := statusColors[card.thread.Status] + if !exists { + statusColor = defaultStatusColor + } + + secondLine := card.thread.Status + hasActivity := card.thread.Activity != "" + if hasActivity { + secondLine = card.thread.Activity + } + + styledSecondLine := lipgloss.NewStyle(). + Foreground(statusColor). + Render(truncate(secondLine, card.width-cardBorderPadding)) + + titleMaxLen := card.width - cardBorderPadding + title := truncate(card.thread.Title, titleMaxLen) + content := fmt.Sprintf("%s\n%s", title, styledSecondLine) + + style := lipgloss.NewStyle(). + Width(card.width). + Height(card.height). + Border(lipgloss.RoundedBorder()). + Padding(0, 1) + + if card.selected { + style = style. + Border(lipgloss.DoubleBorder()). + BorderForeground(lipgloss.Color("39")) + } + + return style.Render(content) +} +``` + +Note: This removes the pinned indicator rendering since the canvas does not use it (the session panel handles pinned display separately via the divider bar). + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/tui -run TestCardRender -v` +Expected: All PASS (including existing `TestCardRenderShowsTitle`, `TestCardRenderShowsStatus`, `TestCardRenderSelectedHighlight`, `TestCardDynamicSize`) + +**Step 5: Commit** + +```bash +git add internal/tui/card.go internal/tui/card_test.go +git commit -m "feat: render activity on session cards" +``` + +--- + +### Task 4: Add AgentReasoningDeltaMsg to bridge + +**Files:** +- Modify: `internal/tui/msgs.go` +- Modify: `internal/tui/bridge.go` +- Modify: `internal/tui/bridge_test.go` + +**Step 1: Write the failing test** + +Add to `internal/tui/bridge_test.go`: + +```go +func TestBridgeAgentReasoningDelta(t *testing.T) { + event := appserver.ProtoEvent{ + Msg: json.RawMessage(`{"type":"agent_reasoning_delta","delta":"Let me think..."}`), + } + msg := ProtoEventToMsg(event) + reasoning, ok := msg.(AgentReasoningDeltaMsg) + if !ok { + t.Fatalf("expected AgentReasoningDeltaMsg, got %T", msg) + } + if reasoning.Delta != "Let me think..." { + t.Errorf("expected Let me think..., got %s", reasoning.Delta) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `go test ./internal/tui -run TestBridgeAgentReasoningDelta -v` +Expected: FAIL — `AgentReasoningDeltaMsg` undefined + +**Step 3: Write minimal implementation** + +Add to `internal/tui/msgs.go`: + +```go +type AgentReasoningDeltaMsg struct { + Delta string +} +``` + +Add case to switch in `ProtoEventToMsg` in `internal/tui/bridge.go`: + +```go +case appserver.EventAgentReasonDelta: + return decodeReasoningDelta(event.Msg) +``` + +Add decoder function to `internal/tui/bridge.go`: + +```go +func decodeReasoningDelta(raw json.RawMessage) tea.Msg { + var delta appserver.AgentDelta + if err := json.Unmarshal(raw, &delta); err != nil { + return nil + } + return AgentReasoningDeltaMsg{Delta: delta.Delta} +} +``` + +Note: Reuses `appserver.AgentDelta` struct since reasoning deltas have the same `{"delta":"..."}` JSON shape. + +**Step 4: Run tests to verify they pass** + +Run: `go test ./internal/tui -run TestBridge -v` +Expected: All PASS + +**Step 5: Commit** + +```bash +git add internal/tui/msgs.go internal/tui/bridge.go internal/tui/bridge_test.go +git commit -m "feat: decode agent_reasoning_delta events in bridge" +``` + +--- + +### Task 5: Wire event handlers to set activity + +**Files:** +- Modify: `internal/tui/app_proto.go` +- Modify: `internal/tui/app.go` (add Update case for new msg type) + +**Step 1: Check current Update switch for msg routing** + +Read `internal/tui/app.go` to find the `Update` method's switch statement and identify where to add the new `AgentReasoningDeltaMsg` case. + +**Step 2: Update handleTaskStarted to set activity** + +In `internal/tui/app_proto.go`, modify `handleTaskStarted`: + +```go +func (app AppModel) handleTaskStarted() (tea.Model, tea.Cmd) { + if app.sessionID == "" { + return app, nil + } + app.store.UpdateStatus(app.sessionID, state.StatusActive, "") + app.store.UpdateActivity(app.sessionID, "Thinking...") + messageID := fmt.Sprintf("msg-%d", time.Now().UnixNano()) + app.currentMessageID = messageID + thread, exists := app.store.Get(app.sessionID) + if exists { + thread.AppendMessage(state.ChatMessage{ + ID: messageID, + Role: "assistant", + Content: "", + }) + } + return app, nil +} +``` + +**Step 3: Update handleAgentDelta to set activity snippet** + +In `internal/tui/app_proto.go`, modify `handleAgentDelta`. After appending the delta, update activity with a snippet of the latest message content: + +```go +func (app AppModel) handleAgentDelta(msg AgentDeltaMsg) (tea.Model, tea.Cmd) { + noSession := app.sessionID == "" + noMessage := app.currentMessageID == "" + if noSession || noMessage { + return app, nil + } + thread, exists := app.store.Get(app.sessionID) + if !exists { + return app, nil + } + thread.AppendDelta(app.currentMessageID, msg.Delta) + snippet := latestMessageSnippet(thread, app.currentMessageID) + app.store.UpdateActivity(app.sessionID, snippet) + return app, nil +} +``` + +**Step 4: Add latestMessageSnippet helper** + +Add to `internal/tui/app_proto.go`: + +```go +const activitySnippetMaxLen = 40 + +func latestMessageSnippet(thread *state.ThreadState, messageID string) string { + for index := range thread.Messages { + if thread.Messages[index].ID != messageID { + continue + } + content := thread.Messages[index].Content + if len(content) <= activitySnippetMaxLen { + return content + } + return content[len(content)-activitySnippetMaxLen:] + } + return "" +} +``` + +**Step 5: Add handleReasoningDelta handler** + +Add to `internal/tui/app_proto.go`: + +```go +func (app AppModel) handleReasoningDelta() (tea.Model, tea.Cmd) { + if app.sessionID != "" { + app.store.UpdateActivity(app.sessionID, "Thinking...") + } + return app, nil +} +``` + +**Step 6: Update handleExecApproval to set activity** + +In `internal/tui/app_proto.go`, modify `handleExecApproval`: + +```go +func (app AppModel) handleExecApproval(msg ExecApprovalRequestMsg) (tea.Model, tea.Cmd) { + if app.sessionID != "" { + activity := fmt.Sprintf("Running: %s", msg.Command) + app.store.UpdateActivity(app.sessionID, activity) + } + if app.client != nil { + app.client.SendApproval(msg.EventID, appserver.OpExecApproval, true) + } + return app, nil +} +``` + +**Step 7: Update handlePatchApproval to set activity** + +In `internal/tui/app_proto.go`, modify `handlePatchApproval`: + +```go +func (app AppModel) handlePatchApproval(msg PatchApprovalRequestMsg) (tea.Model, tea.Cmd) { + if app.sessionID != "" { + app.store.UpdateActivity(app.sessionID, "Applying patch...") + } + if app.client != nil { + app.client.SendApproval(msg.EventID, appserver.OpPatchApproval, true) + } + return app, nil +} +``` + +**Step 8: Update handleAgentMessageCompleted to clear activity** + +In `internal/tui/app_proto.go`, modify `handleAgentMessageCompleted`: + +```go +func (app AppModel) handleAgentMessageCompleted() (tea.Model, tea.Cmd) { + app.currentMessageID = "" + if app.sessionID != "" { + app.store.UpdateActivity(app.sessionID, "") + } + return app, nil +} +``` + +**Step 9: Update handleTaskComplete to clear activity** + +In `internal/tui/app_proto.go`, modify `handleTaskComplete`: + +```go +func (app AppModel) handleTaskComplete() (tea.Model, tea.Cmd) { + if app.sessionID != "" { + app.store.UpdateStatus(app.sessionID, state.StatusCompleted, "") + app.store.UpdateActivity(app.sessionID, "") + } + app.currentMessageID = "" + return app, nil +} +``` + +**Step 10: Add AgentReasoningDeltaMsg case to Update in app.go** + +Find the switch statement in `app.go`'s `Update` method and add: + +```go +case AgentReasoningDeltaMsg: + return app.handleReasoningDelta() +``` + +**Step 11: Run all tests** + +Run: `go test ./... -v -race` +Expected: All PASS + +**Step 12: Run linter** + +Run: `golangci-lint run` +Expected: No issues + +**Step 13: Commit** + +```bash +git add internal/tui/app_proto.go internal/tui/app.go +git commit -m "feat: wire protocol events to session card activity" +``` + +--- + +### Task 6: Verify build and full test suite + +**Step 1: Build** + +Run: `go build -o dj ./cmd/dj` +Expected: Build succeeds + +**Step 2: Full test suite with race detector** + +Run: `go test ./... -v -race` +Expected: All PASS + +**Step 3: Lint** + +Run: `golangci-lint run` +Expected: No issues + +**Step 4: Final commit if any cleanup needed** + +Only if lint or tests revealed issues to fix. diff --git a/internal/state/store.go b/internal/state/store.go index 682d0e9..0c013f2 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -91,6 +91,17 @@ func (store *ThreadStore) UpdateStatus(id string, status string, title string) { } } +func (store *ThreadStore) UpdateActivity(id string, activity string) { + store.mu.Lock() + defer store.mu.Unlock() + + thread, exists := store.threads[id] + if !exists { + return + } + thread.Activity = activity +} + func (store *ThreadStore) Children(parentID string) []*ThreadState { store.mu.RLock() defer store.mu.RUnlock() diff --git a/internal/state/store_test.go b/internal/state/store_test.go index a128c12..f765092 100644 --- a/internal/state/store_test.go +++ b/internal/state/store_test.go @@ -2,75 +2,104 @@ package state import "testing" -func TestStoreAddAndGet(t *testing.T) { +const ( + storeTestThreadID = "t-1" + storeTestSecondID = "t-2" + storeTestMissingID = "missing" + storeTestMyThread = "My Thread" + storeTestTitle = "Test" + storeTestFirstTitle = "First" + storeTestSecondTitle = "Second" + storeTestRunning = "Running" + storeTestActivity = "Running: git status" + storeTestExpectedTwo = 2 +) + +func TestStoreAddAndGet(testing *testing.T) { store := NewThreadStore() - store.Add("t-1", "My Thread") + store.Add(storeTestThreadID, storeTestMyThread) - thread, exists := store.Get("t-1") + thread, exists := store.Get(storeTestThreadID) if !exists { - t.Fatal("expected thread to exist") + testing.Fatal("expected thread to exist") } - if thread.Title != "My Thread" { - t.Errorf("expected My Thread, got %s", thread.Title) + if thread.Title != storeTestMyThread { + testing.Errorf("expected My Thread, got %s", thread.Title) } } -func TestStoreGetMissing(t *testing.T) { +func TestStoreGetMissing(testing *testing.T) { store := NewThreadStore() - _, exists := store.Get("missing") + _, exists := store.Get(storeTestMissingID) if exists { - t.Error("expected thread to not exist") + testing.Error("expected thread to not exist") } } -func TestStoreDelete(t *testing.T) { +func TestStoreDelete(testing *testing.T) { store := NewThreadStore() - store.Add("t-1", "Test") - store.Delete("t-1") + store.Add(storeTestThreadID, storeTestTitle) + store.Delete(storeTestThreadID) - _, exists := store.Get("t-1") + _, exists := store.Get(storeTestThreadID) if exists { - t.Error("expected thread to be deleted") + testing.Error("expected thread to be deleted") } } -func TestStoreAll(t *testing.T) { +func TestStoreAll(testing *testing.T) { store := NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") + store.Add(storeTestThreadID, storeTestFirstTitle) + store.Add(storeTestSecondID, storeTestSecondTitle) all := store.All() - if len(all) != 2 { - t.Fatalf("expected 2 threads, got %d", len(all)) + if len(all) != storeTestExpectedTwo { + testing.Fatalf("expected 2 threads, got %d", len(all)) } } -func TestStoreUpdateStatus(t *testing.T) { +func TestStoreUpdateStatus(testing *testing.T) { store := NewThreadStore() - store.Add("t-1", "Test") - store.UpdateStatus("t-1", StatusActive, "Running") + store.Add(storeTestThreadID, storeTestTitle) + store.UpdateStatus(storeTestThreadID, StatusActive, storeTestRunning) - thread, _ := store.Get("t-1") + thread, _ := store.Get(storeTestThreadID) if thread.Status != StatusActive { - t.Errorf("expected active, got %s", thread.Status) + testing.Errorf("expected active, got %s", thread.Status) } - if thread.Title != "Running" { - t.Errorf("expected Running, got %s", thread.Title) + if thread.Title != storeTestRunning { + testing.Errorf("expected Running, got %s", thread.Title) } } -func TestStoreUpdateStatusMissing(t *testing.T) { +func TestStoreUpdateStatusMissing(testing *testing.T) { store := NewThreadStore() - store.UpdateStatus("missing", StatusActive, "Test") + store.UpdateStatus(storeTestMissingID, StatusActive, storeTestTitle) } -func TestStoreIDs(t *testing.T) { +func TestStoreIDs(testing *testing.T) { store := NewThreadStore() - store.Add("t-1", "First") - store.Add("t-2", "Second") + store.Add(storeTestThreadID, storeTestFirstTitle) + store.Add(storeTestSecondID, storeTestSecondTitle) ids := store.IDs() - if len(ids) != 2 { - t.Fatalf("expected 2 ids, got %d", len(ids)) + if len(ids) != storeTestExpectedTwo { + testing.Fatalf("expected 2 ids, got %d", len(ids)) } } + +func TestStoreUpdateActivity(testing *testing.T) { + store := NewThreadStore() + store.Add(storeTestThreadID, storeTestTitle) + store.UpdateActivity(storeTestThreadID, storeTestActivity) + + thread, _ := store.Get(storeTestThreadID) + if thread.Activity != storeTestActivity { + testing.Errorf("expected %s, got %s", storeTestActivity, thread.Activity) + } +} + +func TestStoreUpdateActivityMissing(testing *testing.T) { + store := NewThreadStore() + store.UpdateActivity(storeTestMissingID, storeTestActivity) +} diff --git a/internal/state/thread.go b/internal/state/thread.go index 4690e96..8b21d81 100644 --- a/internal/state/thread.go +++ b/internal/state/thread.go @@ -17,6 +17,7 @@ type ThreadState struct { ID string Title string Status string + Activity string ParentID string Messages []ChatMessage CommandOutput map[string]string @@ -48,3 +49,11 @@ func (threadState *ThreadState) AppendDelta(messageID string, delta string) { func (threadState *ThreadState) AppendOutput(execID string, data string) { threadState.CommandOutput[execID] += data } + +func (threadState *ThreadState) SetActivity(activity string) { + threadState.Activity = activity +} + +func (threadState *ThreadState) ClearActivity() { + threadState.Activity = "" +} diff --git a/internal/state/thread_test.go b/internal/state/thread_test.go index 724e7b5..dd156bd 100644 --- a/internal/state/thread_test.go +++ b/internal/state/thread_test.go @@ -2,51 +2,81 @@ package state import "testing" -func TestNewThreadState(t *testing.T) { - thread := NewThreadState("t-1", "Build a web app") - if thread.ID != "t-1" { - t.Errorf("expected t-1, got %s", thread.ID) +const ( + testThreadID = "t-1" + testTitle = "Test" + testMessageID = "m-1" + testExecID = "e-1" + testGreeting = "Hello" + testActivity = "Running: git status" + + errExpectedHello = "expected Hello, got %s" +) + +func TestNewThreadState(testing *testing.T) { + thread := NewThreadState(testThreadID, "Build a web app") + if thread.ID != testThreadID { + testing.Errorf("expected t-1, got %s", thread.ID) } if thread.Status != StatusIdle { - t.Errorf("expected idle, got %s", thread.Status) + testing.Errorf("expected idle, got %s", thread.Status) } if len(thread.Messages) != 0 { - t.Errorf("expected 0 messages, got %d", len(thread.Messages)) + testing.Errorf("expected 0 messages, got %d", len(thread.Messages)) } } -func TestThreadStateAppendMessage(t *testing.T) { - thread := NewThreadState("t-1", "Test") +func TestThreadStateAppendMessage(testing *testing.T) { + thread := NewThreadState(testThreadID, testTitle) thread.AppendMessage(ChatMessage{ - ID: "m-1", + ID: testMessageID, Role: "user", - Content: "Hello", + Content: testGreeting, }) if len(thread.Messages) != 1 { - t.Fatalf("expected 1 message, got %d", len(thread.Messages)) + testing.Fatalf("expected 1 message, got %d", len(thread.Messages)) } - if thread.Messages[0].Content != "Hello" { - t.Errorf("expected Hello, got %s", thread.Messages[0].Content) + if thread.Messages[0].Content != testGreeting { + testing.Errorf(errExpectedHello, thread.Messages[0].Content) } } -func TestThreadStateAppendDelta(t *testing.T) { - thread := NewThreadState("t-1", "Test") - thread.AppendMessage(ChatMessage{ID: "m-1", Role: "assistant", Content: "He"}) - thread.AppendDelta("m-1", "llo") +func TestThreadStateAppendDelta(testing *testing.T) { + thread := NewThreadState(testThreadID, testTitle) + thread.AppendMessage(ChatMessage{ID: testMessageID, Role: "assistant", Content: "He"}) + thread.AppendDelta(testMessageID, "llo") - if thread.Messages[0].Content != "Hello" { - t.Errorf("expected Hello, got %s", thread.Messages[0].Content) + if thread.Messages[0].Content != testGreeting { + testing.Errorf(errExpectedHello, thread.Messages[0].Content) } } -func TestThreadStateAppendOutput(t *testing.T) { - thread := NewThreadState("t-1", "Test") - thread.AppendOutput("e-1", "line 1\n") - thread.AppendOutput("e-1", "line 2\n") +func TestThreadStateAppendOutput(testing *testing.T) { + thread := NewThreadState(testThreadID, testTitle) + thread.AppendOutput(testExecID, "line 1\n") + thread.AppendOutput(testExecID, "line 2\n") - output := thread.CommandOutput["e-1"] + output := thread.CommandOutput[testExecID] if output != "line 1\nline 2\n" { - t.Errorf("expected combined output, got %q", output) + testing.Errorf("expected combined output, got %q", output) + } +} + +func TestThreadStateSetActivity(testing *testing.T) { + thread := NewThreadState(testThreadID, testTitle) + thread.SetActivity(testActivity) + + if thread.Activity != testActivity { + testing.Errorf("expected %s, got %s", testActivity, thread.Activity) + } +} + +func TestThreadStateClearActivity(testing *testing.T) { + thread := NewThreadState(testThreadID, testTitle) + thread.SetActivity("Thinking...") + thread.ClearActivity() + + if thread.Activity != "" { + testing.Errorf("expected empty activity, got %s", thread.Activity) } } diff --git a/internal/tui/app.go b/internal/tui/app.go index 1f3a97e..71b1983 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -154,6 +154,8 @@ func (app AppModel) handleAgentMsg(msg tea.Msg) (tea.Model, tea.Cmd) { return app.handleExecApproval(msg) case PatchApprovalRequestMsg: return app.handlePatchApproval(msg) + case AgentReasoningDeltaMsg: + return app.handleReasoningDelta() } return app, nil } diff --git a/internal/tui/app_proto.go b/internal/tui/app_proto.go index 8c5db17..f4b38a3 100644 --- a/internal/tui/app_proto.go +++ b/internal/tui/app_proto.go @@ -10,6 +10,13 @@ import ( "github.com/robinojw/dj/internal/state" ) +const ( + activityThinking = "Thinking..." + activityApplyingPatch = "Applying patch..." + activityRunningPrefix = "Running: " + activitySnippetMaxLen = 40 +) + type protoEventMsg struct { Event appserver.ProtoEvent } @@ -64,6 +71,7 @@ func (app AppModel) handleTaskStarted() (tea.Model, tea.Cmd) { 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) @@ -78,32 +86,49 @@ func (app AppModel) handleTaskStarted() (tea.Model, tea.Cmd) { } func (app AppModel) handleAgentDelta(msg AgentDeltaMsg) (tea.Model, tea.Cmd) { - noSession := app.sessionID == "" - noMessage := app.currentMessageID == "" - if noSession || noMessage { + missingContext := app.sessionID == "" || app.currentMessageID == "" + if missingContext { return app, nil } thread, exists := app.store.Get(app.sessionID) - if exists { - thread.AppendDelta(app.currentMessageID, msg.Delta) + 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) } @@ -111,8 +136,25 @@ func (app AppModel) handleExecApproval(msg ExecApprovalRequestMsg) (tea.Model, t } 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/bridge.go b/internal/tui/bridge.go index 542c4be..0205bf8 100644 --- a/internal/tui/bridge.go +++ b/internal/tui/bridge.go @@ -25,6 +25,8 @@ func ProtoEventToMsg(event appserver.ProtoEvent) tea.Msg { 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: @@ -68,6 +70,14 @@ func decodeAgentMessage(raw json.RawMessage) tea.Msg { 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 { diff --git a/internal/tui/bridge_test.go b/internal/tui/bridge_test.go index 6b8f234..f321fdf 100644 --- a/internal/tui/bridge_test.go +++ b/internal/tui/bridge_test.go @@ -7,7 +7,9 @@ import ( "github.com/robinojw/dj/internal/appserver" ) -func TestBridgeSessionConfigured(t *testing.T) { +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"}`), @@ -15,103 +17,117 @@ func TestBridgeSessionConfigured(t *testing.T) { msg := ProtoEventToMsg(event) configured, ok := msg.(SessionConfiguredMsg) if !ok { - t.Fatalf("expected SessionConfiguredMsg, got %T", msg) + testing.Fatalf("expected SessionConfiguredMsg, got %T", msg) } if configured.SessionID != "s-1" { - t.Errorf("expected s-1, got %s", configured.SessionID) + testing.Errorf("expected s-1, got %s", configured.SessionID) } if configured.Model != "o4-mini" { - t.Errorf("expected o4-mini, got %s", configured.Model) + testing.Errorf("expected o4-mini, got %s", configured.Model) } } -func TestBridgeTaskStarted(t *testing.T) { +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 { - t.Fatalf("expected TaskStartedMsg, got %T", msg) + testing.Fatalf("expected TaskStartedMsg, got %T", msg) } } -func TestBridgeAgentDelta(t *testing.T) { +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 { - t.Fatalf("expected AgentDeltaMsg, got %T", msg) + testing.Fatalf("expected AgentDeltaMsg, got %T", msg) } if delta.Delta != "Hello" { - t.Errorf("expected Hello, got %s", delta.Delta) + testing.Errorf("expected Hello, got %s", delta.Delta) } } -func TestBridgeAgentMessage(t *testing.T) { +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 { - t.Fatalf("expected AgentMessageCompletedMsg, got %T", msg) + testing.Fatalf("expected AgentMessageCompletedMsg, got %T", msg) } if completed.Message != "Hello world" { - t.Errorf("expected Hello world, got %s", completed.Message) + testing.Errorf("expected Hello world, got %s", completed.Message) } } -func TestBridgeTaskComplete(t *testing.T) { +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 { - t.Fatalf("expected TaskCompleteMsg, got %T", msg) + testing.Fatalf("expected TaskCompleteMsg, got %T", msg) } if complete.LastMessage != "Done" { - t.Errorf("expected Done, got %s", complete.LastMessage) + testing.Errorf("expected Done, got %s", complete.LastMessage) } } -func TestBridgeExecApproval(t *testing.T) { +func TestBridgeExecApproval(testing *testing.T) { event := appserver.ProtoEvent{ - ID: "req-1", + ID: testRequestID, Msg: json.RawMessage(`{"type":"exec_command_request","command":"ls","cwd":"/tmp"}`), } msg := ProtoEventToMsg(event) approval, ok := msg.(ExecApprovalRequestMsg) if !ok { - t.Fatalf("expected ExecApprovalRequestMsg, got %T", msg) + testing.Fatalf("expected ExecApprovalRequestMsg, got %T", msg) } - if approval.EventID != "req-1" { - t.Errorf("expected req-1, got %s", approval.EventID) + if approval.EventID != testRequestID { + testing.Errorf("expected %s, got %s", testRequestID, approval.EventID) } if approval.Command != "ls" { - t.Errorf("expected ls, got %s", approval.Command) + 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(t *testing.T) { +func TestBridgeUnknownEventReturnsNil(testing *testing.T) { event := appserver.ProtoEvent{ Msg: json.RawMessage(`{"type":"unknown_event"}`), } msg := ProtoEventToMsg(event) if msg != nil { - t.Errorf("expected nil for unknown event, got %T", msg) + testing.Errorf("expected nil for unknown event, got %T", msg) } } -func TestBridgeInvalidJSONReturnsNil(t *testing.T) { +func TestBridgeInvalidJSONReturnsNil(testing *testing.T) { event := appserver.ProtoEvent{ Msg: json.RawMessage(`not json`), } msg := ProtoEventToMsg(event) if msg != nil { - t.Errorf("expected nil for invalid JSON, got %T", msg) + testing.Errorf("expected nil for invalid JSON, got %T", msg) } } diff --git a/internal/tui/card.go b/internal/tui/card.go index 5603c8f..2f0efbe 100644 --- a/internal/tui/card.go +++ b/internal/tui/card.go @@ -8,11 +8,11 @@ import ( ) const ( - minCardWidth = 20 - maxCardWidth = 50 - minCardHeight = 4 - maxCardHeight = 12 - cardBorderPadding = 4 + minCardWidth = 20 + maxCardWidth = 50 + minCardHeight = 4 + maxCardHeight = 12 + cardBorderPadding = 4 truncateEllipsisLen = 3 ) @@ -66,9 +66,15 @@ func (card CardModel) View() string { statusColor = defaultStatusColor } - statusLine := lipgloss.NewStyle(). + secondLine := card.thread.Status + hasActivity := card.thread.Activity != "" + if hasActivity { + secondLine = card.thread.Activity + } + + styledSecondLine := lipgloss.NewStyle(). Foreground(statusColor). - Render(card.thread.Status) + Render(truncate(secondLine, card.width-cardBorderPadding)) titleMaxLen := card.width - cardBorderPadding if card.pinned { @@ -78,7 +84,7 @@ func (card CardModel) View() string { if card.pinned { title += pinnedIndicator } - content := fmt.Sprintf("%s\n%s", title, statusLine) + content := fmt.Sprintf("%s\n%s", title, styledSecondLine) style := lipgloss.NewStyle(). Width(card.width). diff --git a/internal/tui/card_test.go b/internal/tui/card_test.go index b58ea6b..7de5410 100644 --- a/internal/tui/card_test.go +++ b/internal/tui/card_test.go @@ -8,11 +8,13 @@ import ( ) const ( - testThreadID = "t-1" - testThreadTitle = "Test" - testBuildTitle = "Build web app" - 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 ) func TestCardRenderShowsTitle(testing *testing.T) { @@ -65,6 +67,46 @@ func TestCardDynamicSize(testing *testing.T) { } } +func TestCardRenderShowsActivity(testing *testing.T) { + thread := state.NewThreadState(testThreadID, testThreadTitle) + thread.Status = state.StatusActive + thread.Activity = testActivity + + card := NewCardModel(thread, false, false) + card.SetSize(testCardWidth, testCardHeight) + output := card.View() + + if !strings.Contains(output, testActivity) { + testing.Errorf("expected activity in output, got:\n%s", output) + } +} + +func TestCardRenderFallsBackToStatus(testing *testing.T) { + thread := state.NewThreadState(testThreadID, testThreadTitle) + thread.Status = state.StatusIdle + + card := NewCardModel(thread, false, false) + output := card.View() + + if !strings.Contains(output, "idle") { + testing.Errorf("expected status fallback in output, got:\n%s", output) + } +} + +func TestCardRenderActivityTruncated(testing *testing.T) { + thread := state.NewThreadState(testThreadID, testThreadTitle) + thread.Status = state.StatusActive + thread.Activity = testLongActivity + + card := NewCardModel(thread, false, false) + card.SetSize(minCardWidth, minCardHeight) + output := card.View() + + if !strings.Contains(output, "...") { + testing.Errorf("expected truncated activity with ellipsis, got:\n%s", output) + } +} + func TestCardPinnedShowsIndicator(testing *testing.T) { thread := state.NewThreadState(testThreadID, testBuildTitle) thread.Status = state.StatusActive diff --git a/internal/tui/msgs.go b/internal/tui/msgs.go index 30397b0..1affc03 100644 --- a/internal/tui/msgs.go +++ b/internal/tui/msgs.go @@ -19,6 +19,10 @@ type AgentMessageCompletedMsg struct { Message string } +type AgentReasoningDeltaMsg struct { + Delta string +} + type ExecApprovalRequestMsg struct { EventID string Command string