diff --git a/internal/tool/code_index_manager.go b/internal/tool/code_index_manager.go index c4e11cd1a..4fab7cddf 100644 --- a/internal/tool/code_index_manager.go +++ b/internal/tool/code_index_manager.go @@ -43,6 +43,7 @@ type CodeIndexManager struct { rebuildCh chan struct{} // signaled by MarkDirty to trigger immediate debounced rebuild lockFile *os.File // cross-process flock handle onReady func(stats CodeIndexStats) // optional callback when index build completes + onProgress func(done, total int) // optional throttled build progress (doBuild goroutine) // indexStats tracks basic stats for debugging/logging. stats CodeIndexStats @@ -358,6 +359,17 @@ func (m *CodeIndexManager) doBuild(ctx context.Context) { skipped, indexed := 0, 0 maxTerms := codeIndexMaxTotalTermsOverride() + // Progress throttle: report at most every 400ms so a long walk over a + // large or slow tree updates the "Building code index..." status line + // instead of sitting silent (which reads as a hung UI). The callback + // fires on THIS goroutine and must stay cheap (the REPL just Sends a + // message); snapshot it once - SetOnProgress during a build is not a + // scenario worth locking for. + m.mu.RLock() + progressFn := m.onProgress + m.mu.RUnlock() + lastProgress := time.Now() + for _, absPath := range files { if maxTerms > 0 && totalTerms >= maxTerms { truncated = true @@ -427,6 +439,10 @@ func (m *CodeIndexManager) doBuild(ctx context.Context) { totalTerms += len(tf) totalLength += len(terms) indexed++ + if progressFn != nil && time.Since(lastProgress) >= 400*time.Millisecond { + lastProgress = time.Now() + progressFn(indexed, len(files)) + } } if truncated { debug.Log("codeindex", "term budget reached (%d terms over %d docs) - index truncated at %d of %d files; "+ @@ -493,6 +509,16 @@ func (m *CodeIndexManager) SetOnReady(fn func(stats CodeIndexStats)) { m.mu.Unlock() } +// SetOnProgress registers a throttled callback reporting build progress +// (fired on the doBuild goroutine, at most ~every 400ms). The REPL uses +// it to update the "Building code index..." status line so a long walk +// over a large or slow tree does not read as a hung UI. +func (m *CodeIndexManager) SetOnProgress(fn func(done, total int)) { + m.mu.Lock() + defer m.mu.Unlock() + m.onProgress = fn +} + // collectFiles walks the working directory and returns a list of // indexable source files. func (m *CodeIndexManager) collectFiles(ctx context.Context) []string { diff --git a/internal/tool/code_index_progress_test.go b/internal/tool/code_index_progress_test.go new file mode 100644 index 000000000..67f40fac5 --- /dev/null +++ b/internal/tool/code_index_progress_test.go @@ -0,0 +1,30 @@ +package tool + +import ( + "sync" + "testing" +) + +// SetOnProgress registers without racing and the callback is stored. +// (The throttled firing itself is exercised via doBuild integration in +// the REPL; here we pin the registration contract.) +func TestSetOnProgressRegistration(t *testing.T) { + m := NewCodeIndexManager(t.TempDir()) + var mu sync.Mutex + called := 0 + m.SetOnProgress(func(done, total int) { + mu.Lock() + called++ + mu.Unlock() + }) + if m.onProgress == nil { + t.Fatalf("onProgress must be stored") + } + // Fire it directly (doBuild calls it on its own goroutine). + m.onProgress(1, 2) + mu.Lock() + defer mu.Unlock() + if called != 1 { + t.Fatalf("callback not invoked, called=%d", called) + } +} diff --git a/internal/tui/repl.go b/internal/tui/repl.go index b68bc04fc..e387b267a 100644 --- a/internal/tui/repl.go +++ b/internal/tui/repl.go @@ -1637,8 +1637,17 @@ func (r *REPL) Run() error { if r.agent != nil { if cim := r.agent.CodeIndexManager(); cim != nil { if !cim.IsReady() { - r.program.Send(systemMsg{msg: "Building code index for @ fuzzy search..."}) + r.program.Send(systemNotifyMsg{ItemID: "codeindex-progress", Replace: true, Text: "Building code index for @ fuzzy search..."}) } + // Throttled progress on the same status line: a long walk + // over a large/slow tree stays visibly alive instead of + // reading as a hung UI (screenshot report 2026-09-18). + cim.SetOnProgress(func(done, total int) { + if r.program != nil { + r.program.Send(systemNotifyMsg{ItemID: "codeindex-progress", Replace: true, + Text: fmt.Sprintf("Building code index... %d/%d files", done, total)}) + } + }) cim.SetOnReady(func(stats tool.CodeIndexStats) { if stats.IndexedFiles > 0 && r.program != nil { r.program.Send(systemMsg{msg: fmt.Sprintf("Code index ready: %d files indexed - @ fuzzy search enabled", stats.IndexedFiles)})