Skip to content

Commit c826dbe

Browse files
tinker495claude
andauthored
feat(cli): add Bubble Tea TUI for watch, status, trace, init, and workspace commands (#143)
* feat(cli): add TUI command flows and stabilize watch/status behavior * feat: introduce dynamic watch supervisor to manage multiple worktree sessions with TUI integration and event observation * feat: improve TUI responsiveness for narrow terminals and refactor status file loading with dedicated tests. * fix: Ensure dynamic watch supervisor gracefully shuts down on context cancellation and add a test for this behavior. * fix: resolve TUI lint findings * test: normalize dynamic watch roots across platforms * refactor: introduce dedicated TUI components for ledger and progress using `charmbracelet/bubbles` and refactor `tui_watch` to integrate them. * feat: Enable direct number input for selecting options in the TUI init wizard and add a corresponding test. * feat: introduce progress observers for initial scan and embedding, updating watch TUI with real-time status. * fix watch tui stats baseline and stabilize dynamic watch CI test * style: gofmt dynamic watch retry test * feat(tui): add provider, backend, and RPG config steps to init wizard Extend the TUI init wizard with text-input based configuration steps for embedding provider endpoints/models, storage backend connection details, and RPG (Repository Planning Graph) enablement with optional LLM configuration. Replaces hardcoded defaults with interactive inputs using charmbracelet/bubbles textinput components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(cli): address PR review items for TUI watch/init * feat: Enhance watch daemon stopping logic to support multiple log directories and worktrees, and improve TUI statistics handling with per-project snapshots. * fix: Prevent double-counting of incremental stats when re-basing snapshots in watch UI. * feat: enhance trace TUI to display call graph edges without nodes and add file indexing statistics to watch TUI * refactor: Remove `filesSkipped` metric from the TUI and clear project statistics when sessions are removed. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 055c507 commit c826dbe

39 files changed

+6579
-264
lines changed

cli/init.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ var (
1818
initBackend string
1919
initNonInteractive bool
2020
initInherit bool
21+
initUI bool
2122
)
2223

2324
const (
@@ -43,6 +44,7 @@ func init() {
4344
initCmd.Flags().StringVarP(&initBackend, "backend", "b", "", "Storage backend (gob, postgres, or qdrant)")
4445
initCmd.Flags().BoolVar(&initNonInteractive, "yes", false, "Use defaults without prompting")
4546
initCmd.Flags().BoolVar(&initInherit, "inherit", false, "Inherit configuration from main worktree (for git worktrees)")
47+
initCmd.Flags().BoolVar(&initUI, "ui", false, "Run interactive Bubble Tea UI wizard")
4648
}
4749

4850
func runInit(cmd *cobra.Command, args []string) error {
@@ -60,19 +62,23 @@ func runInit(cmd *cobra.Command, args []string) error {
6062

6163
cfg := config.DefaultConfig()
6264
skipPrompts := false
65+
var detectedGitInfo *git.DetectInfo
66+
var detectedMainCfg *config.Config
6367

6468
// Detect git worktree and offer config inheritance
6569
gitInfo, gitErr := git.Detect(cwd)
6670
if gitErr == nil && gitInfo.IsWorktree && config.Exists(gitInfo.MainWorktree) {
6771
mainCfg, loadErr := config.Load(gitInfo.MainWorktree)
6872
if loadErr == nil {
73+
detectedGitInfo = gitInfo
74+
detectedMainCfg = mainCfg
6975
fmt.Printf("\nGit worktree detected.\n")
7076
fmt.Printf(" Main worktree: %s\n", gitInfo.MainWorktree)
7177
fmt.Printf(" Worktree ID: %s\n", gitInfo.WorktreeID)
7278
fmt.Printf(" Backend: %s\n", mainCfg.Store.Backend)
7379

7480
shouldInherit := initInherit
75-
if !shouldInherit && !initNonInteractive {
81+
if shouldPromptInheritChoice(shouldInherit, initNonInteractive, initUI) {
7682
reader := bufio.NewReader(os.Stdin)
7783
fmt.Print("\nInherit configuration from main worktree? [Y/n]: ")
7884
input, _ := reader.ReadString('\n')
@@ -96,6 +102,15 @@ func runInit(cmd *cobra.Command, args []string) error {
96102
}
97103
}
98104

105+
if initUI && !initNonInteractive {
106+
uiCfg, uiErr := runInitWizardUI(cwd, cfg, detectedGitInfo, detectedMainCfg, initInherit)
107+
if uiErr != nil {
108+
return uiErr
109+
}
110+
cfg = uiCfg
111+
skipPrompts = true
112+
}
113+
99114
// Interactive mode
100115
if !skipPrompts && !initNonInteractive {
101116
reader := bufio.NewReader(os.Stdin)
@@ -334,3 +349,7 @@ func runInit(cmd *cobra.Command, args []string) error {
334349

335350
return nil
336351
}
352+
353+
func shouldPromptInheritChoice(shouldInherit, nonInteractive, uiMode bool) bool {
354+
return !shouldInherit && !nonInteractive && !uiMode
355+
}

cli/init_ui_prompt_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package cli
2+
3+
import "testing"
4+
5+
func TestShouldPromptInheritChoice(t *testing.T) {
6+
if shouldPromptInheritChoice(false, false, true) {
7+
t.Fatal("UI mode should skip legacy inherit prompt")
8+
}
9+
if shouldPromptInheritChoice(false, true, false) {
10+
t.Fatal("non-interactive mode should skip inherit prompt")
11+
}
12+
if shouldPromptInheritChoice(true, false, false) {
13+
t.Fatal("already-selected inherit should skip prompt")
14+
}
15+
if !shouldPromptInheritChoice(false, false, false) {
16+
t.Fatal("interactive non-UI mode should prompt for inherit choice")
17+
}
18+
}

cli/status.go

Lines changed: 168 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,22 @@ package cli
33
import (
44
"context"
55
"fmt"
6+
"os"
7+
"path/filepath"
68
"sort"
79
"strings"
810

911
"github.com/charmbracelet/bubbletea"
1012
"github.com/charmbracelet/lipgloss"
1113
"github.com/spf13/cobra"
1214
"github.com/yoanbernabeu/grepai/config"
15+
"github.com/yoanbernabeu/grepai/daemon"
16+
"github.com/yoanbernabeu/grepai/git"
1317
"github.com/yoanbernabeu/grepai/store"
1418
)
1519

20+
var statusNoUI bool
21+
1622
var statusCmd = &cobra.Command{
1723
Use: "status",
1824
Short: "Display index status and browse indexed files",
@@ -45,9 +51,18 @@ type model struct {
4551
selectedChunk int
4652
width int
4753
height int
54+
watchRunning bool
55+
watchPID int
56+
watchLogDir string
57+
watchLogFile string
58+
worktreeID string
4859
err error
4960
}
5061

62+
func init() {
63+
statusCmd.Flags().BoolVar(&statusNoUI, "no-ui", false, "Print plain text summary instead of interactive UI")
64+
}
65+
5166
// Styles
5267
var (
5368
titleStyle = lipgloss.NewStyle().
@@ -185,6 +200,19 @@ func (m model) viewStats() string {
185200
sb.WriteString(normalStyle.Render("Provider: "))
186201
sb.WriteString(fmt.Sprintf("%s (%s)\n", m.cfg.Embedder.Provider, m.cfg.Embedder.Model))
187202

203+
sb.WriteString(normalStyle.Render("Watcher status: "))
204+
if m.watchRunning {
205+
sb.WriteString(fmt.Sprintf("running (PID %d)\n", m.watchPID))
206+
} else {
207+
sb.WriteString("not running\n")
208+
}
209+
sb.WriteString(normalStyle.Render("Watcher logs: "))
210+
if m.watchLogFile == "" {
211+
sb.WriteString("N/A\n")
212+
} else {
213+
sb.WriteString(fmt.Sprintf("%s\n", m.watchLogFile))
214+
}
215+
188216
sb.WriteString("\n")
189217
sb.WriteString(helpStyle.Render("[Enter] Browse files [q] Quit"))
190218

@@ -349,24 +377,31 @@ func runStatus(cmd *cobra.Command, args []string) error {
349377
return fmt.Errorf("failed to get stats: %w", err)
350378
}
351379

352-
// Get files
353-
files, err := st.ListFilesWithStats(ctx)
380+
watchStatus := resolveWatcherRuntimeStatus(projectRoot)
381+
useUI := shouldUseStatusUI(isInteractiveTerminal(), statusNoUI)
382+
383+
if !useUI {
384+
fmt.Print(renderStatusSummary(cfg, stats, watchStatus))
385+
return nil
386+
}
387+
388+
files, err := loadStatusFiles(ctx, useUI, st.ListFilesWithStats)
354389
if err != nil {
355390
return fmt.Errorf("failed to list files: %w", err)
356391
}
357392

358-
// Sort files by path
359-
sort.Slice(files, func(i, j int) bool {
360-
return files[i].Path < files[j].Path
361-
})
362-
363393
// Create model
364394
m := model{
365-
st: st,
366-
cfg: cfg,
367-
state: viewStats,
368-
stats: stats,
369-
files: files,
395+
st: st,
396+
cfg: cfg,
397+
state: viewStats,
398+
stats: stats,
399+
files: files,
400+
watchRunning: watchStatus.running,
401+
watchPID: watchStatus.pid,
402+
watchLogDir: watchStatus.logDir,
403+
watchLogFile: watchStatus.logFile,
404+
worktreeID: watchStatus.worktreeID,
370405
}
371406

372407
// Run TUI
@@ -397,3 +432,124 @@ func truncatePath(path string, maxLen int) string {
397432
}
398433
return "..." + path[len(path)-maxLen+3:]
399434
}
435+
436+
type watcherRuntimeStatus struct {
437+
running bool
438+
pid int
439+
logDir string
440+
logFile string
441+
worktreeID string
442+
}
443+
444+
func resolveWatcherRuntimeStatus(projectRoot string) watcherRuntimeStatus {
445+
status := watcherRuntimeStatus{}
446+
447+
logDirs, err := resolveWatcherCandidateLogDirs(projectRoot)
448+
if err != nil {
449+
return status
450+
}
451+
if len(logDirs) == 0 {
452+
return status
453+
}
454+
455+
cwd, err := os.Getwd()
456+
var worktreeID string
457+
if err == nil {
458+
gitInfo, gitErr := git.Detect(cwd)
459+
if gitErr == nil && gitInfo.WorktreeID != "" {
460+
worktreeID = gitInfo.WorktreeID
461+
}
462+
}
463+
464+
for idx, logDir := range logDirs {
465+
status.logDir = logDir
466+
status.worktreeID = worktreeID
467+
if worktreeID != "" {
468+
pid, _ := daemon.GetRunningWorktreePID(logDir, worktreeID)
469+
logFile := daemon.GetWorktreeLogFile(logDir, worktreeID)
470+
if pid == 0 {
471+
legacyPID, _ := daemon.GetRunningPID(logDir)
472+
if legacyPID > 0 {
473+
pid = legacyPID
474+
logFile = filepath.Join(logDir, "grepai-watch.log")
475+
}
476+
}
477+
status.pid = pid
478+
status.running = pid > 0
479+
status.logFile = logFile
480+
} else {
481+
pid, _ := daemon.GetRunningPID(logDir)
482+
status.pid = pid
483+
status.running = pid > 0
484+
status.logFile = filepath.Join(logDir, "grepai-watch.log")
485+
}
486+
if status.running || idx == len(logDirs)-1 {
487+
return status
488+
}
489+
}
490+
491+
return status
492+
}
493+
494+
func resolveWatcherCandidateLogDirs(projectRoot string) ([]string, error) {
495+
defaultLogDir, err := daemon.GetDefaultLogDir()
496+
if err != nil {
497+
return nil, err
498+
}
499+
500+
logDirs := make([]string, 0, 2)
501+
if projectRoot != "" {
502+
hintedLogDir, readErr := readWatchLogDirHint(projectRoot)
503+
if readErr == nil && hintedLogDir != "" {
504+
hintedLogDir = filepath.Clean(hintedLogDir)
505+
if hintedLogDir != filepath.Clean(defaultLogDir) {
506+
logDirs = append(logDirs, hintedLogDir)
507+
}
508+
}
509+
}
510+
511+
logDirs = append(logDirs, defaultLogDir)
512+
return logDirs, nil
513+
}
514+
515+
func renderStatusSummary(cfg *config.Config, stats *store.IndexStats, watch watcherRuntimeStatus) string {
516+
var sb strings.Builder
517+
sb.WriteString("grepai index status\n")
518+
sb.WriteString(fmt.Sprintf("Files indexed: %d\n", stats.TotalFiles))
519+
sb.WriteString(fmt.Sprintf("Total chunks: %d\n", stats.TotalChunks))
520+
sb.WriteString(fmt.Sprintf("Index size: %s\n", formatBytes(stats.IndexSize)))
521+
if stats.LastUpdated.IsZero() {
522+
sb.WriteString("Last updated: Never\n")
523+
} else {
524+
sb.WriteString(fmt.Sprintf("Last updated: %s\n", stats.LastUpdated.Format("2006-01-02 15:04:05")))
525+
}
526+
sb.WriteString(fmt.Sprintf("Provider: %s (%s)\n", cfg.Embedder.Provider, cfg.Embedder.Model))
527+
if watch.running {
528+
sb.WriteString(fmt.Sprintf("Watcher: running (PID %d)\n", watch.pid))
529+
} else {
530+
sb.WriteString("Watcher: not running\n")
531+
}
532+
if watch.logFile != "" {
533+
sb.WriteString(fmt.Sprintf("Watcher log: %s\n", watch.logFile))
534+
}
535+
return sb.String()
536+
}
537+
538+
func loadStatusFiles(
539+
ctx context.Context,
540+
useUI bool,
541+
listFn func(context.Context) ([]store.FileStats, error),
542+
) ([]store.FileStats, error) {
543+
if !useUI {
544+
return nil, nil
545+
}
546+
547+
files, err := listFn(ctx)
548+
if err != nil {
549+
return nil, err
550+
}
551+
sort.Slice(files, func(i, j int) bool {
552+
return files[i].Path < files[j].Path
553+
})
554+
return files, nil
555+
}

cli/status_files_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/yoanbernabeu/grepai/store"
8+
)
9+
10+
func TestLoadStatusFiles_SkipsWhenUIOff(t *testing.T) {
11+
called := 0
12+
13+
files, err := loadStatusFiles(context.Background(), false, func(context.Context) ([]store.FileStats, error) {
14+
called++
15+
return []store.FileStats{{Path: "b"}, {Path: "a"}}, nil
16+
})
17+
if err != nil {
18+
t.Fatalf("loadStatusFiles() error: %v", err)
19+
}
20+
if called != 0 {
21+
t.Fatalf("list function called %d times, want 0", called)
22+
}
23+
if len(files) != 0 {
24+
t.Fatalf("files length = %d, want 0", len(files))
25+
}
26+
}
27+
28+
func TestLoadStatusFiles_LoadsAndSortsWhenUIOn(t *testing.T) {
29+
files, err := loadStatusFiles(context.Background(), true, func(context.Context) ([]store.FileStats, error) {
30+
return []store.FileStats{
31+
{Path: "z-last.go"},
32+
{Path: "a-first.go"},
33+
{Path: "m-mid.go"},
34+
}, nil
35+
})
36+
if err != nil {
37+
t.Fatalf("loadStatusFiles() error: %v", err)
38+
}
39+
if len(files) != 3 {
40+
t.Fatalf("files length = %d, want 3", len(files))
41+
}
42+
if files[0].Path != "a-first.go" || files[1].Path != "m-mid.go" || files[2].Path != "z-last.go" {
43+
t.Fatalf("files not sorted by path: %+v", files)
44+
}
45+
}

0 commit comments

Comments
 (0)