Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions internal/i18n/i18n.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "筛选"},
Expand Down
177 changes: 168 additions & 9 deletions internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package tui
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
Expand Down Expand Up @@ -88,6 +89,7 @@ type sessionDiscoveryMsg struct {
sessions []loadedSession
cacheEntries int
cacheValid int
sourceCounts map[string]int
}

// loadProgressMsg 承载一批渐进加载结果。
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)),
Expand All @@ -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()
Expand Down
65 changes: 64 additions & 1 deletion internal/tui/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2659,13 +2659,48 @@ 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))
}
}
}

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
Expand All @@ -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)
Expand Down