From f4696a95ebd6a69a7eac2051c96e4ec3b0df9ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=89=E5=93=B2?= Date: Mon, 4 May 2026 22:47:20 +0800 Subject: [PATCH] Improve loading progress context --- internal/i18n/i18n.go | 8 ++ internal/tui/tui.go | 177 +++++++++++++++++++++++++++++++++++++-- internal/tui/tui_test.go | 65 +++++++++++++- 3 files changed, 240 insertions(+), 10 deletions(-) diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index 0902e8a..52167f1 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -833,6 +833,14 @@ var translations = map[string]map[Lang]string{ "loading_scanning_hint": {EN: "Scanning known agent directories. Press q to quit.", ZH: "正在扫描已知 Agent 目录。按 q 退出。"}, "loading_parsing_hint": {EN: "Parsing and analyzing session files. Press q to quit.", ZH: "正在解析和分析会话文件。按 q 退出。"}, "loading_from_cache": {EN: "%d from cache", ZH: "%d 个来自缓存"}, + "loading_phase": {EN: "Phase", ZH: "阶段"}, + "loading_phase_find": {EN: "discovery", ZH: "发现"}, + "loading_phase_cache": {EN: "cache restore", ZH: "缓存恢复"}, + "loading_phase_parse": {EN: "parsing", ZH: "解析"}, + "loading_phase_agg": {EN: "aggregation", ZH: "聚合"}, + "loading_parsed": {EN: "parsed %d/%d", ZH: "已解析 %d/%d"}, + "loading_sources": {EN: "sources", ZH: "来源"}, + "loading_src_pending": {EN: "sources pending discovery", ZH: "来源等待发现"}, "cache_status": {EN: "cache hits %d/%d valid (%d entries)", ZH: "缓存命中 %d/%d 有效(%d 条)"}, "scroll_label": {EN: "Scroll", ZH: "滚动"}, "list_filter": {EN: "filter", ZH: "筛选"}, diff --git a/internal/tui/tui.go b/internal/tui/tui.go index a932c78..a8506e0 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -5,6 +5,7 @@ package tui import ( "fmt" "os" + "path/filepath" "sort" "strings" "unicode/utf8" @@ -88,6 +89,7 @@ type sessionDiscoveryMsg struct { sessions []loadedSession cacheEntries int cacheValid int + sourceCounts map[string]int } // loadProgressMsg 承载一批渐进加载结果。 @@ -109,15 +111,16 @@ type Model struct { lang i18n.Lang // Progressive loading state - loading bool - loadProgress int - loadTotal int - loadedFromCache int - cacheEntries int - cacheValid int - loadQueue []string - sessionCache engine.SessionCache - unsavedNewCount int + loading bool + loadProgress int + loadTotal int + loadedFromCache int + cacheEntries int + cacheValid int + loadSourceCounts map[string]int + loadQueue []string + sessionCache engine.SessionCache + unsavedNewCount int // Overview data overview engine.Overview @@ -242,6 +245,7 @@ func (m *Model) startReload() tea.Cmd { m.loadedFromCache = 0 m.cacheEntries = 0 m.cacheValid = 0 + m.loadSourceCounts = nil m.unsavedNewCount = 0 m.loading = true @@ -307,11 +311,13 @@ func discoverSessionFilesCmd(dir string, cache engine.SessionCache) tea.Cmd { files: files, cacheEntries: cache.EntryCount(), cacheValid: cacheValid, + sourceCounts: loadingSourceCounts(files, nil, cache), } if dir == "" { for _, s := range engine.LoadSQLiteBackedSessions() { msg.sessions = append(msg.sessions, loadedSession{session: s}) } + msg.sourceCounts = loadingSourceCounts(files, msg.sessions, cache) } return msg } @@ -362,6 +368,56 @@ func loadNextCmd(files []string, cache engine.SessionCache, idx int) tea.Cmd { } } +func loadingSourceCounts(files []string, sessions []loadedSession, cache engine.SessionCache) map[string]int { + counts := make(map[string]int) + for _, loaded := range sessions { + source := loaded.session.Metrics.SourceTool + if source == "" { + source = "generic" + } + counts[source]++ + } + for _, path := range files { + source := "" + if s, ok := engine.CachedSession(path, cache); ok { + source = s.Metrics.SourceTool + } + if source == "" { + source = loadingSourceFromPath(path) + } + counts[source]++ + } + return counts +} + +func loadingSourceFromPath(path string) string { + p := strings.ToLower(filepath.ToSlash(path)) + switch { + case strings.Contains(p, "/.hermes/"): + return "hermes_jsonl" + case strings.Contains(p, "/.codex/"): + return "codex_cli" + case strings.Contains(p, "/.claude/"): + return "claude_code" + case strings.Contains(p, "/.gemini/"): + return "gemini_cli" + case strings.Contains(p, "/.qwen/"): + return "qwen_code" + case strings.Contains(p, "/opencode/") || strings.Contains(p, "application support/opencode"): + return "opencode" + case strings.Contains(p, "/.omp/"): + return "oh_my_pi" + case strings.Contains(p, "cline-dev"): + return "cline" + case strings.Contains(p, "/cursor/"): + return "cursor" + case strings.Contains(p, "aider"): + return "aider" + default: + return "generic" + } +} + // ── Update ── func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -378,6 +434,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.loadTotal = len(msg.files) + len(msg.sessions) m.cacheEntries = msg.cacheEntries m.cacheValid = msg.cacheValid + m.loadSourceCounts = msg.sourceCounts if len(msg.sessions) > 0 { m.appendLoadedSessions(msg.sessions) } @@ -964,6 +1021,9 @@ func (m Model) renderLoading() string { lines := []string{ boldStyle.Render(i18n.T("loading_discovering")), "", + m.renderLoadingStatusLine(0, 0, contentW), + m.renderLoadingSourceCounts(contentW), + "", dimStyle.Render(truncate(i18n.T("loading_scanning_hint"), contentW)), } return strings.Join([]string{ @@ -1000,6 +1060,9 @@ func (m Model) renderLoading() string { lines := []string{ boldStyle.Render(i18n.T("loading_sessions")), "", + m.renderLoadingStatusLine(progress, pct, contentW), + m.renderLoadingSourceCounts(contentW), + "", fmt.Sprintf(" %s %d/%d %d%%%s", bar, progress, m.loadTotal, pct, cacheInfo), "", dimStyle.Render(truncate(i18n.T("loading_parsing_hint"), contentW)), @@ -1011,6 +1074,102 @@ func (m Model) renderLoading() string { }, "\n") } +func (m Model) renderLoadingStatusLine(progress, pct, width int) string { + phase := m.loadingPhase(progress) + cache := m.loadingCacheLabel() + parsed := fmt.Sprintf(i18n.T("loading_parsed"), len(m.sessions), maxInt(m.loadTotal, 0)) + if m.loadTotal <= 0 { + parsed = fmt.Sprintf(i18n.T("loading_parsed"), 0, 0) + } + line := fmt.Sprintf("%s %s · %s · %s · %d%%", + i18n.T("loading_phase"), + phase, + parsed, + cache, + pct, + ) + return truncate(line, width) +} + +func (m Model) loadingPhase(progress int) string { + if m.loadTotal <= 0 { + return i18n.T("loading_phase_find") + } + if progress >= m.loadTotal { + return i18n.T("loading_phase_agg") + } + if m.cacheValid > 0 && m.loadedFromCache < m.cacheValid { + return i18n.T("loading_phase_cache") + } + return i18n.T("loading_phase_parse") +} + +func (m Model) loadingCacheLabel() string { + hits := m.loadedFromCache + if hits < 0 { + hits = 0 + } + valid := m.cacheValid + if valid < 0 { + valid = 0 + } + if hits > valid && valid > 0 { + hits = valid + } + entries := m.cacheEntries + if entries < valid { + entries = valid + } + if entries < 0 { + entries = 0 + } + return fmt.Sprintf(i18n.T("cache_status"), hits, valid, entries) +} + +func (m Model) renderLoadingSourceCounts(width int) string { + if len(m.loadSourceCounts) == 0 { + return dimStyle.Render(truncate(i18n.T("loading_src_pending"), width)) + } + type item struct { + source string + count int + } + items := make([]item, 0, len(m.loadSourceCounts)) + for source, count := range m.loadSourceCounts { + if count <= 0 { + continue + } + items = append(items, item{source: source, count: count}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].count == items[j].count { + return sourceDisplayName(items[i].source) < sourceDisplayName(items[j].source) + } + return items[i].count > items[j].count + }) + if len(items) == 0 { + return dimStyle.Render(truncate(i18n.T("loading_src_pending"), width)) + } + limit := 4 + if width < 70 { + limit = 2 + } + var parts []string + for i, it := range items { + if i >= limit { + remaining := 0 + for _, rest := range items[i:] { + remaining += rest.count + } + parts = append(parts, fmt.Sprintf("+%d", remaining)) + break + } + parts = append(parts, fmt.Sprintf("%s %d", sourceDisplayName(it.source), it.count)) + } + line := fmt.Sprintf("%s %s", i18n.T("loading_sources"), strings.Join(parts, " · ")) + return dimStyle.Render(truncate(line, width)) +} + func (m Model) View() string { header := m.renderAppHeader() tabs := m.renderTabs() diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 1290a32..497a9ef 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -2659,6 +2659,10 @@ func TestLoadingRenderWithinTerminalWidth(t *testing.T) { m.loading = true m.loadProgress = 3 m.loadTotal = 10 + m.cacheValid = 4 + m.cacheEntries = 6 + m.loadedFromCache = 2 + m.loadSourceCounts = map[string]int{"claude_code": 7, "codex_cli": 3, "gemini_cli": 1} rendered := m.View() if got := maxRenderedWidth(rendered); got > width { t.Fatalf("loading render too wide: width=%d got=%d line=%q", width, got, widestLine(rendered)) @@ -2666,6 +2670,37 @@ func TestLoadingRenderWithinTerminalWidth(t *testing.T) { } } +func TestLoadingRenderShowsPhaseCacheAndSourceCounts(t *testing.T) { + prev := i18n.Current + i18n.SetLang(i18n.EN) + t.Cleanup(func() { i18n.SetLang(prev) }) + + m := resizeForTest(t, sampleModelForTest(), 120, 30) + m.loading = true + m.loadProgress = 5 + m.loadTotal = 10 + m.loadedFromCache = 2 + m.cacheValid = 4 + m.cacheEntries = 8 + m.loadSourceCounts = map[string]int{"claude_code": 6, "codex_cli": 3, "gemini_cli": 1} + + rendered := m.View() + for _, want := range []string{ + "Phase cache restore", + "parsed 3/10", + "cache hits 2/4 valid (8 entries)", + "sources Claude Code 6", + "Codex CLI 3", + } { + if !strings.Contains(rendered, want) { + t.Fatalf("loading screen missing %q:\n%s", want, rendered) + } + } + if got := maxRenderedWidth(rendered); got > 120 { + t.Fatalf("loading render too wide: got=%d line=%q", got, widestLine(rendered)) + } +} + func TestLoadingRenderClampsProgressPastTotal(t *testing.T) { m := resizeForTest(t, New("__missing_test_sessions__"), 80, 24) m.loading = true @@ -2674,11 +2709,39 @@ func TestLoadingRenderClampsProgressPastTotal(t *testing.T) { rendered := m.View() - if !strings.Contains(rendered, "2/2") || !strings.Contains(rendered, "100%") { + if !strings.Contains(rendered, "2/2") || !strings.Contains(rendered, "100%") || !strings.Contains(rendered, "aggregation") { t.Fatalf("expected clamped loading progress, got:\n%s", rendered) } } +func TestLoadingSourceCountsUsesCacheAndPathFallback(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "a.jsonl") + if err := os.WriteFile(path, []byte("{}\n"), 0644); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + cache := engine.SessionCache{Entries: map[string]engine.CacheEntry{ + path: { + ModTime: info.ModTime().UnixNano(), + Size: info.Size(), + Session: engine.Session{Metrics: engine.Metrics{SourceTool: "claude_code"}}, + }, + }} + counts := loadingSourceCounts( + []string{path, "/Users/test/.gemini/tmp/b.jsonl"}, + []loadedSession{{session: engine.Session{Metrics: engine.Metrics{SourceTool: "hermes_jsonl"}}}}, + cache, + ) + + if counts["claude_code"] != 1 || counts["gemini_cli"] != 1 || counts["hermes_jsonl"] != 1 { + t.Fatalf("bad source counts: %#v", counts) + } +} + func TestChineseViewsRenderWithinTerminalWidth(t *testing.T) { prev := i18n.Current i18n.SetLang(i18n.ZH)