diff --git a/internal/agent/completion_runtime.go b/internal/agent/completion_runtime.go index 5061cb36..fe06ce89 100644 --- a/internal/agent/completion_runtime.go +++ b/internal/agent/completion_runtime.go @@ -21,6 +21,14 @@ func (e *defaultEngine) completeTurn(ctx context.Context, request llm.ChatReques return runner.client.CreateMessage(ctx, request) } + request.OnStreamProgress = func(charCount int, active bool) { + runner.emit(Event{ + Type: EventThinkingProgress, + ReasoningCharCount: charCount, + ReasoningActive: active, + }) + } + reply, err := runner.client.StreamMessage(ctx, request, func(delta string) { if delta != "" { emitTurnEvent(ctx, TurnEvent{ diff --git a/internal/agent/events.go b/internal/agent/events.go index 447516ee..0f1f5f3a 100644 --- a/internal/agent/events.go +++ b/internal/agent/events.go @@ -17,6 +17,7 @@ const ( EventPlanUpdated EventType = "plan_updated" EventUsageUpdated EventType = "usage_updated" EventRunFinished EventType = "run_finished" + EventThinkingProgress EventType = "thinking_progress" ) type Event struct { @@ -33,6 +34,10 @@ type Event struct { Usage llm.Usage AgentID string // non-empty when emitted by a subagent InvocationID string // non-empty when emitted by a subagent, globally unique per invocation + + // EventThinkingProgress fields — reasoning progress without exposing content. + ReasoningCharCount int + ReasoningActive bool } type Observer interface { diff --git a/internal/agent/runner_usage_stream_test.go b/internal/agent/runner_usage_stream_test.go index a0d65b31..181f56fa 100644 --- a/internal/agent/runner_usage_stream_test.go +++ b/internal/agent/runner_usage_stream_test.go @@ -78,3 +78,68 @@ func TestRunPromptEmitsUsageEventForStreamingReplyUsage(t *testing.T) { t.Fatalf("expected exactly 1 usage event, got %d", usageEvents) } } + +type reasoningProgressStreamClient struct { + reply llm.Message +} + +func (c *reasoningProgressStreamClient) CreateMessage(context.Context, llm.ChatRequest) (llm.Message, error) { + return c.reply, nil +} + +func (c *reasoningProgressStreamClient) StreamMessage(_ context.Context, req llm.ChatRequest, onDelta func(string)) (llm.Message, error) { + if req.OnStreamProgress != nil { + req.OnStreamProgress(50, true) + req.OnStreamProgress(100, false) + } + if onDelta != nil && strings.TrimSpace(c.reply.Content) != "" { + onDelta(c.reply.Content) + } + return c.reply, nil +} + +func TestCompleteTurnEmitsThinkingProgressViaOnStreamProgress(t *testing.T) { + workspace := t.TempDir() + store, err := session.NewStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + sess := session.New(workspace) + + var events []Event + runner := NewRunner(Options{ + Workspace: workspace, + Config: config.Config{ + Provider: config.ProviderConfig{Model: "test-model"}, + MaxIterations: 1, + Stream: true, + }, + Client: &reasoningProgressStreamClient{reply: llm.Message{ + Role: llm.RoleAssistant, + Content: "final answer", + }}, + Store: store, + Registry: tools.DefaultRegistry(), + Observer: ObserverFunc(func(event Event) { events = append(events, event) }), + Stdin: strings.NewReader(""), + Stdout: io.Discard, + }) + + if _, err := runner.RunPrompt(context.Background(), sess, "hello", "build", io.Discard); err != nil { + t.Fatal(err) + } + + progressEvents := 0 + for _, event := range events { + if event.Type != EventThinkingProgress { + continue + } + progressEvents++ + if event.ReasoningCharCount != 50 && event.ReasoningCharCount != 100 { + t.Fatalf("unexpected reasoning char count: %d", event.ReasoningCharCount) + } + } + if progressEvents != 2 { + t.Fatalf("expected exactly 2 thinking progress events, got %d", progressEvents) + } +} diff --git a/internal/app/tui_adapter.go b/internal/app/tui_adapter.go index 48313003..08b8b1aa 100644 --- a/internal/app/tui_adapter.go +++ b/internal/app/tui_adapter.go @@ -190,19 +190,21 @@ func (a *tuiRunnerAdapter) DispatchSubAgent( func mapAgentEvent(event agent.Event) tui.Event { return tui.Event{ - Type: mapAgentEventType(event.Type), - SessionID: string(event.SessionID), - UserInput: event.UserInput, - Content: event.Content, - ToolName: event.ToolName, - ToolCallID: event.ToolCallID, - ToolArguments: event.ToolArguments, - ToolResult: event.ToolResult, - Error: event.Error, - Plan: event.Plan, - Usage: event.Usage, - AgentID: event.AgentID, - InvocationID: event.InvocationID, + Type: mapAgentEventType(event.Type), + SessionID: string(event.SessionID), + UserInput: event.UserInput, + Content: event.Content, + ToolName: event.ToolName, + ToolCallID: event.ToolCallID, + ToolArguments: event.ToolArguments, + ToolResult: event.ToolResult, + Error: event.Error, + Plan: event.Plan, + Usage: event.Usage, + AgentID: event.AgentID, + InvocationID: event.InvocationID, + ReasoningCharCount: event.ReasoningCharCount, + ReasoningActive: event.ReasoningActive, } } @@ -224,6 +226,8 @@ func mapAgentEventType(value agent.EventType) tui.EventType { return tui.EventUsageUpdated case agent.EventRunFinished: return tui.EventRunFinished + case agent.EventThinkingProgress: + return tui.EventThinkingProgress default: return tui.EventType(value) } diff --git a/internal/app/tui_adapter_test.go b/internal/app/tui_adapter_test.go index d8168a75..17fe765b 100644 --- a/internal/app/tui_adapter_test.go +++ b/internal/app/tui_adapter_test.go @@ -167,16 +167,18 @@ func TestTUIRunnerAdapterNilGuardMethods(t *testing.T) { func TestMapAgentEventAndType(t *testing.T) { source := agent.Event{ - Type: agent.EventToolCallCompleted, - SessionID: "sess-1", - UserInput: "/review check changes", - Content: "done", - ToolName: "delegate_subagent", - ToolArguments: `{"agent":"review"}`, - ToolResult: `{"ok":true}`, - Error: "none", - Plan: planpkg.State{}, - Usage: llm.Usage{InputTokens: 12, OutputTokens: 34}, + Type: agent.EventToolCallCompleted, + SessionID: "sess-1", + UserInput: "/review check changes", + Content: "done", + ToolName: "delegate_subagent", + ToolArguments: `{"agent":"review"}`, + ToolResult: `{"ok":true}`, + Error: "none", + Plan: planpkg.State{}, + Usage: llm.Usage{InputTokens: 12, OutputTokens: 34}, + ReasoningCharCount: 200, + ReasoningActive: true, } mapped := mapAgentEvent(source) @@ -189,10 +191,16 @@ func TestMapAgentEventAndType(t *testing.T) { if mapped.Usage.InputTokens != 12 || mapped.Usage.OutputTokens != 34 { t.Fatalf("expected mapped usage to be preserved, got %#v", mapped.Usage) } + if mapped.ReasoningCharCount != 200 || !mapped.ReasoningActive { + t.Fatalf("expected reasoning progress fields to be mapped, got charCount=%d active=%v", mapped.ReasoningCharCount, mapped.ReasoningActive) + } if got := mapAgentEventType(agent.EventRunStarted); got != tui.EventRunStarted { t.Fatalf("expected run-started mapping, got %q", got) } + if got := mapAgentEventType(agent.EventThinkingProgress); got != tui.EventThinkingProgress { + t.Fatalf("expected thinking-progress mapping, got %q", got) + } const unknown = agent.EventType("custom") if got := mapAgentEventType(unknown); got != tui.EventType(unknown) { t.Fatalf("expected fallback mapping for unknown event type, got %q", got) diff --git a/internal/llm/types.go b/internal/llm/types.go index fa67e235..7bc329ce 100644 --- a/internal/llm/types.go +++ b/internal/llm/types.go @@ -341,6 +341,13 @@ type ChatRequest struct { Tools []ToolDefinition Assets map[AssetID]ImageAsset Temperature float64 + + // OnStreamProgress is called during streaming when the provider receives + // non-content progress updates (e.g. reasoning_content chunks from + // DeepSeek-style models). charCount is the cumulative number of + // characters received so far; active is true while reasoning is still + // streaming. + OnStreamProgress func(charCount int, active bool) } type Client interface { diff --git a/internal/mention/index.go b/internal/mention/index.go index c5cc0a29..4f97b4c4 100644 --- a/internal/mention/index.go +++ b/internal/mention/index.go @@ -2,6 +2,7 @@ package mention import ( "bufio" + "io" "io/fs" "log" "os" @@ -20,6 +21,9 @@ const ( mentionTrieRecallMultiplier = 8 mentionTrieMinRecall = 64 mentionIgnoreMaxLineBytes = 4 * 1024 * 1024 + mentionIndexSeedMaxFiles = 200 + mentionIndexSeedMaxVisits = 1000 + mentionIndexSeedMaxDepth = 2 ) var ( @@ -50,6 +54,8 @@ type IndexStats struct { MaxFiles int Truncated bool Ready bool + Building bool + Partial bool } type mentionIgnoreMatcher struct { @@ -76,6 +82,11 @@ type WorkspaceFileIndex struct { trie *mentionTrie truncated bool maxFiles int + partial bool +} + +var startMentionIndexAsyncRebuild = func(idx *WorkspaceFileIndex, root string, maxFiles int) { + go idx.rebuildStarted(root, maxFiles) } func NewWorkspaceFileIndex(workspace string) *WorkspaceFileIndex { @@ -129,7 +140,7 @@ func (idx *WorkspaceFileIndex) SearchWithRecency(query string, limit int, recenc limit = defaultSearchLimit } - idx.ensureInitialBuild() + idx.ensureSeeded() idx.ensureFreshAsync() files, trie := idx.snapshotSearchData() if len(files) == 0 { @@ -187,7 +198,7 @@ func (idx *WorkspaceFileIndex) Prewarm() { if idx == nil { return } - idx.ensureInitialBuild() + idx.rebuildBlocking() } func (idx *WorkspaceFileIndex) Stats() IndexStats { @@ -201,6 +212,8 @@ func (idx *WorkspaceFileIndex) Stats() IndexStats { MaxFiles: idx.maxFiles, Truncated: idx.truncated, Ready: idx.ready, + Building: idx.building, + Partial: idx.partial, } } @@ -215,35 +228,43 @@ func (idx *WorkspaceFileIndex) snapshotSearchData() ([]Candidate, *mentionTrie) return out, idx.trie } -func (idx *WorkspaceFileIndex) ensureInitialBuild() { +func (idx *WorkspaceFileIndex) ensureSeeded() { if idx == nil { return } idx.mu.RLock() - ready := idx.ready + hasData := idx.ready || len(idx.files) > 0 idx.mu.RUnlock() - if ready { + if hasData { return } - idx.rebuildBlocking() + + root := idx.root + maxFiles := idx.maxFiles + matcher := loadMentionIgnoreMatcher(root) + files, truncated := buildMentionSeed(root, maxFiles, matcher) + + idx.mu.Lock() + defer idx.mu.Unlock() + if idx.ready || len(idx.files) > 0 { + return + } + idx.files = files + idx.trie = newMentionTrie(files) + idx.truncated = truncated + idx.partial = len(files) > 0 } func (idx *WorkspaceFileIndex) ensureFreshAsync() { if idx == nil { return } - idx.mu.RLock() - stale := idx.shouldRebuildLocked() - ready := idx.ready - building := idx.building - idx.mu.RUnlock() + idx.mu.Lock() + root, maxFiles, ok := idx.startRebuildLocked() + idx.mu.Unlock() - if !ready { - idx.rebuildBlocking() - return - } - if stale && !building { - go idx.rebuildBlocking() + if ok { + startMentionIndexAsyncRebuild(idx, root, maxFiles) } } @@ -252,15 +273,24 @@ func (idx *WorkspaceFileIndex) rebuildBlocking() { return } idx.mu.Lock() - if idx.building || !idx.shouldRebuildLocked() { - idx.mu.Unlock() + root, maxFiles, ok := idx.startRebuildLocked() + idx.mu.Unlock() + if !ok { return } + + idx.rebuildStarted(root, maxFiles) +} + +func (idx *WorkspaceFileIndex) startRebuildLocked() (root string, maxFiles int, ok bool) { + if idx == nil || idx.building || !idx.shouldRebuildLocked() { + return "", 0, false + } idx.building = true - root := idx.root - maxFiles := idx.maxFiles - idx.mu.Unlock() + return idx.root, idx.maxFiles, true +} +func (idx *WorkspaceFileIndex) rebuildStarted(root string, maxFiles int) { matcher := loadMentionIgnoreMatcher(root) files, truncated := buildMentionIndex(root, maxFiles, matcher) @@ -269,6 +299,7 @@ func (idx *WorkspaceFileIndex) rebuildBlocking() { idx.trie = newMentionTrie(files) idx.truncated = truncated idx.ready = true + idx.partial = false idx.lastBuild = time.Now() idx.building = false idx.mu.Unlock() @@ -380,6 +411,140 @@ func buildMentionIndex(root string, maxFiles int, matcher mentionIgnoreMatcher) return files, truncated } +func buildMentionSeed(root string, maxFiles int, matcher mentionIgnoreMatcher) ([]Candidate, bool) { + root = strings.TrimSpace(root) + if root == "" { + return nil, false + } + if maxFiles <= 0 { + maxFiles = mentionIndexDefaultMaxFiles + } + limit := minInt(maxFiles, mentionIndexSeedMaxFiles) + if limit <= 0 { + return nil, false + } + + maxVisits := minInt(mentionMaxVisitsFromEnv(), mentionIndexSeedMaxVisits) + maxDirs := mentionMaxDirsFromEnv() + maxDuration := mentionMaxDurationFromEnv() + started := time.Now() + + type pendingDir struct { + abs string + rel string + depth int + } + + files := make([]Candidate, 0, minInt(limit, 64)) + queue := []pendingDir{{abs: root}} + truncated := false + visits := 0 + dirs := 0 + + for len(queue) > 0 && len(files) < limit { + current := queue[0] + queue = queue[1:] + + remainingVisits := maxVisits - visits + if remainingVisits <= 0 { + truncated = true + break + } + + entries, hitReadLimit := readMentionSeedDir(current.abs, remainingVisits) + if hitReadLimit { + truncated = true + } + + for _, entry := range entries { + if maxDuration > 0 && time.Since(started) > maxDuration { + truncated = true + break + } + visits++ + if visits > maxVisits { + truncated = true + break + } + + name := entry.Name() + rel := name + if current.rel != "" { + rel = filepath.ToSlash(filepath.Join(current.rel, name)) + } + + if entry.IsDir() { + dirs++ + if dirs > maxDirs { + truncated = true + break + } + if shouldSkipMentionDir(name) || matcher.SkipDir(name, rel) { + continue + } + files = append(files, Candidate{ + Path: rel + "/", + BaseName: name, + Kind: "dir", + }) + if len(files) >= limit { + truncated = true + break + } + if current.depth+1 < mentionIndexSeedMaxDepth { + queue = append(queue, pendingDir{ + abs: filepath.Join(current.abs, name), + rel: rel, + depth: current.depth + 1, + }) + } + continue + } + if entry.Type()&fs.ModeSymlink != 0 { + continue + } + if shouldSkipMentionFile(name) || matcher.SkipFile(name, rel) { + continue + } + files = append(files, Candidate{ + Path: rel, + BaseName: filepath.Base(rel), + TypeTag: mentionTypeTag(rel), + }) + if len(files) >= limit { + truncated = true + break + } + } + } + + sort.Slice(files, func(i, j int) bool { + return files[i].Path < files[j].Path + }) + return files, truncated +} + +func readMentionSeedDir(dir string, limit int) ([]fs.DirEntry, bool) { + if limit <= 0 { + return nil, true + } + f, err := os.Open(dir) + if err != nil { + return nil, false + } + defer f.Close() + + entries, err := f.ReadDir(limit) + hitLimit := err == nil && len(entries) == limit + if err != nil && err != io.EOF { + return nil, false + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + return entries, hitLimit +} + func mentionCandidateIndices(files []Candidate, trie *mentionTrie, query string, limit int) ([]int, map[int]struct{}) { if len(files) == 0 { return nil, nil diff --git a/internal/mention/index_test.go b/internal/mention/index_test.go index 0e7dbefc..6e03934c 100644 --- a/internal/mention/index_test.go +++ b/internal/mention/index_test.go @@ -188,6 +188,68 @@ func TestWorkspaceFileIndexSearchWithRecencyPrioritizesRecent(t *testing.T) { } } +func TestWorkspaceFileIndexColdSearchUsesSeedAndSchedulesRebuild(t *testing.T) { + workspace := t.TempDir() + mustWriteMentionFile(t, filepath.Join(workspace, "shallow.go"), "package main") + mustWriteMentionFile(t, filepath.Join(workspace, "deep", "a", "b", "target.go"), "package main") + + oldStart := startMentionIndexAsyncRebuild + calls := 0 + startMentionIndexAsyncRebuild = func(_ *WorkspaceFileIndex, _ string, _ int) { + calls++ + } + t.Cleanup(func() { + startMentionIndexAsyncRebuild = oldStart + }) + + index := NewWorkspaceFileIndex(workspace) + results := index.Search("shallow", 10) + if len(results) == 0 || results[0].Path != "shallow.go" { + t.Fatalf("expected cold search to return seed result shallow.go, got %#v", results) + } + if calls != 1 { + t.Fatalf("expected cold search to schedule async rebuild once, got %d", calls) + } + stats := index.Stats() + if !stats.Partial || !stats.Building || stats.Ready { + t.Fatalf("expected partial building stats before full rebuild, got %+v", stats) + } + + files, _ := index.snapshotSearchData() + paths := make([]string, 0, len(files)) + for _, item := range files { + paths = append(paths, item.Path) + } + if containsString(paths, "deep/a/b/target.go") { + t.Fatalf("expected seed index not to include deep target file, got %v", paths) + } +} + +func TestWorkspaceFileIndexAsyncRebuildReplacesSeed(t *testing.T) { + workspace := t.TempDir() + mustWriteMentionFile(t, filepath.Join(workspace, "shallow.go"), "package main") + mustWriteMentionFile(t, filepath.Join(workspace, "deep", "a", "b", "target.go"), "package main") + + index := NewWorkspaceFileIndex(workspace) + _ = index.Search("shallow", 10) + + deadline := time.Now().Add(800 * time.Millisecond) + for { + results := index.Search("target", 10) + if len(results) > 0 && results[0].Path == "deep/a/b/target.go" { + stats := index.Stats() + if !stats.Ready || stats.Partial { + t.Fatalf("expected full index stats after async rebuild, got %+v", stats) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("expected async rebuild to find deep target file") + } + time.Sleep(20 * time.Millisecond) + } +} + func TestWorkspaceFileIndexUsesTriePrefixRecall(t *testing.T) { idx := NewStaticWorkspaceFileIndex([]Candidate{ {Path: "README.md"}, diff --git a/internal/provider/openai.go b/internal/provider/openai.go index 27f5cbbc..4804b657 100644 --- a/internal/provider/openai.go +++ b/internal/provider/openai.go @@ -196,6 +196,9 @@ func (c *OpenAICompatible) StreamMessage(ctx context.Context, req llm.ChatReques } if delta.Reasoning != "" { appendOpenAIReasoningContent(&assembled, delta.Reasoning) + if req.OnStreamProgress != nil { + req.OnStreamProgress(len(openAIReasoningContent(assembled)), true) + } } if delta.Content != "" { assembled.Content += delta.Content @@ -261,6 +264,12 @@ func (c *OpenAICompatible) StreamMessage(ctx context.Context, req llm.ChatReques } assembled.Normalize() + if req.OnStreamProgress != nil { + reasoningChars := len(openAIReasoningContent(assembled)) + if reasoningChars > 0 { + req.OnStreamProgress(reasoningChars, false) + } + } return assembled, nil } diff --git a/internal/provider/openai_test.go b/internal/provider/openai_test.go index 2dcf7935..85b737e9 100644 --- a/internal/provider/openai_test.go +++ b/internal/provider/openai_test.go @@ -616,6 +616,68 @@ func TestOpenAIMessagesDoesNotSendReasoningToProviderWithoutReasoningContent(t * } } +func TestOpenAICompatibleStreamMessageFiresProgressCallbackForReasoningContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(strings.Join([]string{ + `data: {"choices":[{"delta":{"role":"assistant","reasoning_content":"thinking step 1"}}]}`, + `data: {"choices":[{"delta":{"reasoning_content":" step 2"}}]}`, + `data: {"choices":[{"delta":{"content":"actual answer"}}]}`, + `data: [DONE]`, + "", + }, "\n"))) + })) + defer server.Close() + + client := NewOpenAICompatible(Config{BaseURL: server.URL, APIKey: "test-key", Model: "fallback-model"}) + + var progressCalls []struct { + charCount int + active bool + } + req := llm.ChatRequest{ + OnStreamProgress: func(charCount int, active bool) { + progressCalls = append(progressCalls, struct { + charCount int + active bool + }{charCount, active}) + }, + } + msg, err := client.StreamMessage(context.Background(), req, nil) + if err != nil { + t.Fatal(err) + } + + // Reasoning should trigger at least 2 progress calls (one per reasoning delta). + if len(progressCalls) < 2 { + t.Fatalf("expected at least 2 progress calls for reasoning chunks, got %d", len(progressCalls)) + } + + // All intermediate calls should be active=true. + for i, call := range progressCalls[:len(progressCalls)-1] { + if !call.active { + t.Fatalf("progress call[%d]: expected active=true, got false", i) + } + } + + // The final call (after streaming loop) should be active=false. + last := progressCalls[len(progressCalls)-1] + if last.active { + t.Fatalf("final progress call: expected active=false after stream ends, got true") + } + if last.charCount <= 0 { + t.Fatalf("final progress call: expected positive charCount, got %d", last.charCount) + } + + // Reasoning content must NOT appear in assembled message content. + if msg.Content != "actual answer" { + t.Fatalf("expected message content to exclude reasoning, got %q", msg.Content) + } + if got := openAIReasoningContent(msg); got != "thinking step 1 step 2" { + t.Fatalf("expected reasoning in Meta, got %q", got) + } +} + func TestOpenAIMessagesDegradesMissingImageAsset(t *testing.T) { messages, err := openAIMessages(llm.ChatRequest{ Messages: []llm.Message{{ diff --git a/tui/component_chat_stream.go b/tui/component_chat_stream.go index 0173d7aa..3f850dd1 100644 --- a/tui/component_chat_stream.go +++ b/tui/component_chat_stream.go @@ -271,6 +271,8 @@ func (m *model) updateThinkingCard() { item.Status = "thinking" if strings.TrimSpace(item.Body) == "" { item.Body = m.thinkingText() + } else if m.reasoningProgressActive { + item.Body = "receiving hidden reasoning..." } } @@ -296,6 +298,23 @@ func (m *model) ensureThinkingCard() { m.streamingIndex = len(m.chatItems) - 1 } +func (m *model) applyReasoningProgress(_ int, active bool) { + m.reasoningProgressActive = active + m.ensureThinkingCard() + if m.streamingIndex < 0 || m.streamingIndex >= len(m.chatItems) { + return + } + if item := &m.chatItems[m.streamingIndex]; item.Kind == "assistant" && (item.Status == "pending" || item.Status == "thinking") { + item.Title = thinkingLabel + item.Status = "thinking" + if active { + item.Body = "receiving hidden reasoning..." + } else { + item.Body = m.thinkingText() + } + } +} + func (m *model) failLatestAssistant(errText string) { errText = strings.TrimSpace(errText) if errText == "" { diff --git a/tui/component_chat_stream_test.go b/tui/component_chat_stream_test.go index a0b00201..e9718470 100644 --- a/tui/component_chat_stream_test.go +++ b/tui/component_chat_stream_test.go @@ -943,6 +943,125 @@ func TestHandleAgentEventPlanUpdatedCopiesPlan(t *testing.T) { } } +func TestHandleAgentEventThinkingProgressCreatesThinkingCard(t *testing.T) { + m := model{ + phase: "thinking", + spinner: spinner.New(), + } + m.handleAgentEvent(Event{ + Type: EventThinkingProgress, + ReasoningCharCount: 150, + ReasoningActive: true, + }) + + if !m.reasoningProgressActive { + t.Fatal("expected reasoningProgressActive to be true after progress event") + } + if m.phase != "thinking" { + t.Fatalf("expected thinking phase, got %q", m.phase) + } + if len(m.chatItems) != 1 { + t.Fatalf("expected 1 chat item (thinking card), got %d", len(m.chatItems)) + } + item := m.chatItems[0] + if item.Kind != "assistant" { + t.Fatalf("expected assistant kind, got %q", item.Kind) + } + if item.Status != "thinking" { + t.Fatalf("expected thinking status, got %q", item.Status) + } + if !strings.Contains(item.Body, "receiving hidden reasoning") { + t.Fatalf("expected reasoning progress note in body, got %q", item.Body) + } + // Reasoning content must NOT appear in the body. + if strings.Contains(item.Body, "step") { + t.Fatalf("body must not contain reasoning content, got %q", item.Body) + } +} + +func TestHandleAgentEventThinkingProgressInactiveRemovesNote(t *testing.T) { + m := model{ + phase: "thinking", + reasoningProgressActive: true, + spinner: spinner.New(), + } + m.handleAgentEvent(Event{ + Type: EventThinkingProgress, + ReasoningCharCount: 300, + ReasoningActive: false, + }) + + if m.reasoningProgressActive { + t.Fatal("expected reasoningProgressActive to be false after inactive event") + } + if len(m.chatItems) != 1 { + t.Fatalf("expected thinking card, got %d items", len(m.chatItems)) + } + if strings.Contains(m.chatItems[0].Body, "receiving hidden reasoning") { + t.Fatalf("expected reasoning note removed, got %q", m.chatItems[0].Body) + } +} + +func TestHandleAgentEventAssistantDeltaClearsReasoningProgress(t *testing.T) { + m := model{ + phase: "thinking", + reasoningProgressActive: true, + } + m.handleAgentEvent(Event{Type: EventAssistantDelta, Content: "Here is the answer."}) + + if m.reasoningProgressActive { + t.Fatal("expected reasoningProgressActive to be cleared by assistant delta") + } + if m.phase != "responding" { + t.Fatalf("expected responding phase, got %q", m.phase) + } +} + +func TestHandleAgentEventToolCallStartedClearsReasoningProgress(t *testing.T) { + m := model{ + phase: "thinking", + reasoningProgressActive: true, + chatItems: []chatEntry{ + {Kind: "assistant", Title: thinkingLabel, Body: "thinking...", Status: "thinking"}, + }, + streamingIndex: 0, + } + m.handleAgentEvent(Event{Type: EventToolCallStarted, ToolName: "list_files"}) + + if m.reasoningProgressActive { + t.Fatal("expected reasoningProgressActive to be cleared by tool call start") + } +} + +func TestUpdateThinkingCardPreservesReasoningNote(t *testing.T) { + m := model{ + busy: true, + streamingIndex: 0, + reasoningProgressActive: true, + chatItems: []chatEntry{ + {Kind: "assistant", Title: thinkingLabel, Body: "old body", Status: "thinking"}, + }, + } + m.updateThinkingCard() + + if m.chatItems[0].Body != "receiving hidden reasoning..." { + t.Fatalf("expected reasoning note to be preserved, got %q", m.chatItems[0].Body) + } +} + +func TestApplyReasoningProgressSkipsWhenItemIsNotThinkingAssistant(t *testing.T) { + m := model{ + streamingIndex: 0, + chatItems: []chatEntry{ + {Kind: "tool", Title: "TOOL | list_files", Body: "done", Status: "done"}, + }, + } + m.applyReasoningProgress(100, true) + if m.chatItems[0].Body != "done" { + t.Fatalf("expected tool item to be untouched, got %q", m.chatItems[0].Body) + } +} + // --- Rate limit error scenarios (from screenshot) --- func TestRunFailedWithToolResultErrorContent(t *testing.T) { diff --git a/tui/component_palettes.go b/tui/component_palettes.go index df13ee1d..4b5d6654 100644 --- a/tui/component_palettes.go +++ b/tui/component_palettes.go @@ -157,8 +157,17 @@ func (m model) renderMentionPalette() string { metaText := "* recent + file/dir * agent Type @query Up/Down Enter/Tab insert Esc close" if m.mentionIndex != nil { stats := m.mentionIndex.Stats() - if stats.Truncated && stats.MaxFiles > 0 { + switch { + case stats.Partial && stats.Building: + metaText = "* recent indexing... showing partial results Enter/Tab insert Esc close" + case stats.Partial: + metaText = "* recent showing partial results Enter/Tab insert Esc close" + case stats.Building: + metaText = "* recent refreshing index... Enter/Tab insert Esc close" + case stats.Truncated && stats.MaxFiles > 0: metaText = fmt.Sprintf("* recent indexed first %d files Enter/Tab insert Esc close", stats.MaxFiles) + case stats.Ready: + metaText = "* recent index ready Type @query Up/Down Enter/Tab insert Esc close" } } rows = append(rows, commandPaletteMetaStyle.Render(metaText)) diff --git a/tui/component_run_flow.go b/tui/component_run_flow.go index aa17fe53..e3fffd13 100644 --- a/tui/component_run_flow.go +++ b/tui/component_run_flow.go @@ -35,6 +35,7 @@ func (m *model) beginRunWithInput(promptInput RunPromptInput, mode, note string) } m.statusNote = note m.phase = "thinking" + m.reasoningProgressActive = false m.llmConnected = true m.busy = true m.runStartedAt = time.Now() @@ -210,7 +211,13 @@ func (m *model) handleAgentEvent(event Event) { case EventRunStarted: m.tempEstimatedOutput = 0 m.lastTokenReceivedAt = time.Now() + case EventThinkingProgress: + m.phase = "thinking" + m.llmConnected = true + m.lastTokenReceivedAt = time.Now() + m.applyReasoningProgress(event.ReasoningCharCount, event.ReasoningActive) case EventAssistantDelta: + m.reasoningProgressActive = false m.phase = "responding" m.statusNote = "LLM is responding..." m.llmConnected = true @@ -222,6 +229,7 @@ func (m *model) handleAgentEvent(event Event) { m.lastTokenReceivedAt = time.Now() m.finishAssistantMessage(event.Content) case EventToolCallStarted: + m.reasoningProgressActive = false m.phase = "tool" m.llmConnected = true m.lastTokenReceivedAt = time.Now() diff --git a/tui/model.go b/tui/model.go index 3be86cd9..94469b6d 100644 --- a/tui/model.go +++ b/tui/model.go @@ -415,6 +415,7 @@ type model struct { runIndicatorState runIndicatorState streamingIndex int suppressedAssistantDelta string + reasoningProgressActive bool statusNote string phase string llmConnected bool diff --git a/tui/model_test.go b/tui/model_test.go index 9d6d7274..75a76385 100644 --- a/tui/model_test.go +++ b/tui/model_test.go @@ -5261,6 +5261,27 @@ func TestRenderMentionPaletteShowsTruncatedMeta(t *testing.T) { } } +func TestRenderMentionPaletteShowsReadyMeta(t *testing.T) { + index := mention.NewStaticWorkspaceFileIndex([]mention.Candidate{ + {Path: "a.go", BaseName: "a.go", TypeTag: "go"}, + }, 0, false) + + m := model{ + screen: screenChat, + width: 100, + mentionOpen: true, + mentionResults: []mention.Candidate{ + {Path: "a.go", BaseName: "a.go", TypeTag: "go"}, + }, + mentionIndex: index, + } + + view := m.renderMentionPalette() + if !strings.Contains(view, "index ready") { + t.Fatalf("expected mention palette to show ready hint, got %q", view) + } +} + func TestCommandPaletteAllowsTypingJKWhenOpen(t *testing.T) { input := textarea.New() input.Focus() diff --git a/tui/ports.go b/tui/ports.go index ca381a99..a32420c2 100644 --- a/tui/ports.go +++ b/tui/ports.go @@ -25,6 +25,7 @@ const ( EventPlanUpdated EventType = "plan_updated" EventUsageUpdated EventType = "usage_updated" EventRunFinished EventType = "run_finished" + EventThinkingProgress EventType = "thinking_progress" ) type Event struct { @@ -41,6 +42,10 @@ type Event struct { Usage llm.Usage AgentID string // non-empty when emitted by a subagent InvocationID string // non-empty when emitted by a subagent, globally unique per invocation + + // EventThinkingProgress fields — reasoning progress without exposing content. + ReasoningCharCount int + ReasoningActive bool } type ApprovalRequest struct {