package internal import ( "bufio" "bytes" "context" "encoding/json" "log" "os" "os/exec" "path/filepath" "sort" "strings" "time" "charm.land/bubbletea/v2" ) // Session 单个会话的元数据 type Session struct { ID string // UUID (文件名) CustomTitle string // customTitle 字段 Cwd string // 实际工作目录 StartTime time.Time // 首条 timestamp EndTime time.Time // 末条 timestamp MsgCount int // user + assistant 消息数 FirstMsg string // 首条用户消息 (截断) AwaySummary string // away_summary 系统摘要 AISummary string // AI 生成的摘要 (缓存) Completed []string // AI 提取的已完成项 Pending []string // AI 提取的待办项 FilePath string // JSONL 文件完整路径 ForkFrom string // 分叉来源 session ID(空表示非分叉) } // ProjectDir 按目录分组的会话集合 type ProjectDir struct { Dir string // 完整路径 DirShort string // 显示名 (路径最后一段) Sessions []*Session // 按时间倒序 } // DisplayTitle 返回会话显示标题 func (s *Session) DisplayTitle() string { return cleanText(s.displayTitleRaw()) } func (s *Session) displayTitleRaw() string { if s.AISummary != "" { return s.AISummary } if s.FirstMsg != "" { return s.FirstMsg } if s.CustomTitle != "" { return s.CustomTitle } return s.ID[:8] } // DisplaySummary 返回会话摘要 func (s *Session) DisplaySummary() string { return cleanText(s.displaySummaryRaw()) } func (s *Session) displaySummaryRaw() string { if s.AwaySummary != "" { return s.AwaySummary } return s.FirstMsg } // cleanText 清理显示文本:多空格保留一个,换行转空格 func cleanText(s string) string { s = strings.ReplaceAll(s, "\r\n", " ") s = strings.ReplaceAll(s, "\n", " ") s = strings.ReplaceAll(s, "\t", " ") for strings.Contains(s, " ") { s = strings.ReplaceAll(s, " ", " ") } return strings.TrimSpace(s) } // HistoryState HISTORY Tab 视图状态 type HistoryState struct { Projects []*ProjectDir DirCursor int // 左栏光标 SessCursor int // 中栏光标 FocusPanel int // 0=左栏 1=中栏 Loaded bool Scanning bool Favorites map[string]bool Cache map[string]*cacheEntry // 内存缓存,避免重复 I/O } // IsHistoryTab 判断当前是否在 HISTORY Tab func (m *Model) IsHistoryTab() bool { return m.activeGroup == len(Groups) } // --- 扫描 --- type ScanCompleteMsg struct { Projects []*ProjectDir Cache map[string]*cacheEntry // 更新后的缓存 UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要 } func ScanSessionsCmd() tea.Cmd { return func() tea.Msg { projects, cache, updated := scanAllProjects() return ScanCompleteMsg{Projects: projects, Cache: cache, UpdatedIDs: updated} } } func scanAllProjects() ([]*ProjectDir, map[string]*cacheEntry, map[string]bool) { home, err := os.UserHomeDir() if err != nil { return nil, nil, nil } projectsDir := filepath.Join(home, ".claude", "projects") entries, err := os.ReadDir(projectsDir) if err != nil { return nil, nil, nil } cache, projTimes := loadCacheWithTimes(home) dirMap := make(map[string]*ProjectDir) updatedIDs := make(map[string]bool) seenIDs := make(map[string]bool) for _, entry := range entries { if !entry.IsDir() { continue } projectPath := filepath.Join(projectsDir, entry.Name()) info, err := entry.Info() if err != nil { continue } // 项目目录 modTime 未变 → 从缓存直接重建该目录下的会话 if prev, ok := projTimes[projectPath]; ok && prev.Equal(info.ModTime()) { for _, id := range rebuildFromCache(cache, dirMap, projectPath) { seenIDs[id] = true } continue } // 目录有变更,记录新 modTime projTimes[projectPath] = info.ModTime() files, err := os.ReadDir(projectPath) if err != nil { continue } for _, f := range files { if f.IsDir() || !strings.HasSuffix(f.Name(), ".jsonl") { continue } sessionID := strings.TrimSuffix(f.Name(), ".jsonl") seenIDs[sessionID] = true filePath := filepath.Join(projectPath, f.Name()) finfo, err := f.Info() if err != nil { continue } if cached, ok := cache[sessionID]; ok && cached.ModTime.Equal(finfo.ModTime()) { addSessionToDir(dirMap, cached.ToSession(sessionID)) continue } session := scanSessionFile(filePath, sessionID) if session == nil || session.Cwd == "" { continue } // 清除旧摘要,标记需要重新生成 session.AISummary = "" session.Completed = nil session.Pending = nil updatedIDs[sessionID] = true addSessionToDir(dirMap, session) cache[sessionID] = cacheEntryFrom(session, finfo.ModTime()) } } // 裁剪缓存:删除磁盘已不存在的条目 for id := range cache { if !seenIDs[id] { delete(cache, id) } } // 清理已不存在项目的 projTimes activeProjects := make(map[string]bool, len(entries)) for _, e := range entries { if e.IsDir() { activeProjects[filepath.Join(projectsDir, e.Name())] = true } } for p := range projTimes { if !activeProjects[p] { delete(projTimes, p) } } saveCacheWithTimes(home, cache, projTimes) result := make([]*ProjectDir, 0, len(dirMap)) for _, pd := range dirMap { sort.Slice(pd.Sessions, func(i, j int) bool { return pd.Sessions[i].StartTime.After(pd.Sessions[j].StartTime) }) result = append(result, pd) } sort.Slice(result, func(i, j int) bool { return result[i].Dir < result[j].Dir }) return result, cache, updatedIDs } // rebuildFromCache 从缓存重建指定项目目录下的所有会话,返回匹配的 session ID func rebuildFromCache(cache map[string]*cacheEntry, dirMap map[string]*ProjectDir, projectPath string) []string { prefix := filepath.Base(projectPath) var seen []string for id, e := range cache { if filepath.Base(filepath.Dir(e.FilePath)) == prefix { addSessionToDir(dirMap, e.ToSession(id)) seen = append(seen, id) } } return seen } func addSessionToDir(dirMap map[string]*ProjectDir, s *Session) { cwd := strings.ReplaceAll(s.Cwd, "\\", "/") pd, ok := dirMap[cwd] if !ok { pd = &ProjectDir{Dir: cwd, DirShort: filepath.Base(cwd)} dirMap[cwd] = pd } pd.Sessions = append(pd.Sessions, s) } // --- JSONL 扫描 --- const scanLineLimit = 500 func scanSessionFile(path string, sessionID string) *Session { f, err := os.Open(path) if err != nil { return nil } defer f.Close() s := &Session{ID: sessionID, FilePath: path} scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) lineCount := 0 firstMsgFound := false for scanner.Scan() { lineCount++ line := scanner.Bytes() // 前 20 行内出现 queue-operation → claude -p 会话,跳过 if lineCount <= 20 && matchType(line, "queue-operation") { return nil } if !hasType(line) { continue } // cwd — 统一为正斜杠 if s.Cwd == "" { extractField(line, `"cwd":"`, &s.Cwd) s.Cwd = strings.ReplaceAll(s.Cwd, "\\", "/") } // timestamp var ts string if extractField(line, `"timestamp":"`, &ts) { if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { if s.StartTime.IsZero() || t.Before(s.StartTime) { s.StartTime = t } if t.After(s.EndTime) { s.EndTime = t } } } switch { case matchType(line, "custom-title"): extractField(line, `"customTitle":"`, &s.CustomTitle) case matchSubtype(line, "away_summary"): extractField(line, `"content":"`, &s.AwaySummary) case matchType(line, "user"): if bytes.Contains(line, []byte(`"tool_use_id"`)) || bytes.Contains(line, []byte(`"isMeta":true`)) { continue } s.MsgCount++ if !firstMsgFound { if msg := extractUserMsg(line); msg != "" { s.FirstMsg = truncStr(msg, 80) firstMsgFound = true } } case matchType(line, "assistant"): s.MsgCount++ } if lineCount > scanLineLimit { break } } return s } // matchType 检查 JSON 行是否包含指定 type 值 func matchType(line []byte, typeName string) bool { compact := `"type":"` + typeName + `"` spaced := `"type": "` + typeName + `"` return bytes.Contains(line, []byte(compact)) || bytes.Contains(line, []byte(spaced)) } // matchSubtype 检查 JSON 行是否包含指定 subtype 值 func matchSubtype(line []byte, subName string) bool { compact := `"subtype":"` + subName + `"` spaced := `"subtype": "` + subName + `"` return bytes.Contains(line, []byte(compact)) || bytes.Contains(line, []byte(spaced)) } // hasType 快速过滤:行是否包含 type 字段 func hasType(line []byte) bool { return bytes.Contains(line, []byte(`"type":"`)) || bytes.Contains(line, []byte(`"type": "`)) } // extractField 从 JSON 行中提取 "key":"value" 的值 func extractField(line []byte, prefix string, out *string) bool { idx := bytes.Index(line, []byte(prefix)) if idx < 0 { return false } // prefix 长度 = len("key\":"),data 从引号开始 return extractJSONString(line[idx+len(prefix)-1:], out) } func extractUserMsg(line []byte) string { // string 格式: "content":"xxx" if idx := bytes.Index(line, []byte(`"content":"`)); idx >= 0 { var content string extractJSONString(line[idx+10:], &content) if content != "" && !strings.HasPrefix(content, "= 0 { searchStart := max(0, idx-200) region := line[searchStart:] if tidx := bytes.Index(region, []byte(`"text":"`)); tidx >= 0 { var text string if extractJSONString(region[tidx+7:], &text) && text != "" && !strings.HasPrefix(text, "Base directory") { return text } } } return "" } // extractJSONString 从 " 开头的 JSON 数据中提取字符串值 func extractJSONString(data []byte, out *string) bool { if len(data) == 0 || data[0] != '"' { return false } // 找结束引号(处理转义) var s string if json.Unmarshal(data[:findStringEnd(data)], &s) == nil { *out = s return true } return false } // findStringEnd 从 " 开始找到结束引号的位置(含) func findStringEnd(data []byte) int { escaped := false for i := 1; i < len(data); i++ { if escaped { escaped = false continue } if data[i] == '\\' { escaped = true continue } if data[i] == '"' { return i + 1 } } return len(data) } // --- 缓存 --- const cacheVersion = 2 type cacheFile struct { Version int `json:"version"` Sessions map[string]*cacheEntry `json:"sessions"` ProjectTimes map[string]time.Time `json:"projectTimes"` // 项目目录 modTime } type cacheEntry struct { ModTime time.Time `json:"modTime"` CustomTitle string `json:"customTitle"` Cwd string `json:"cwd"` StartTime time.Time `json:"startTime"` EndTime time.Time `json:"endTime"` MsgCount int `json:"msgCount"` FirstMsg string `json:"firstMsg"` AwaySummary string `json:"awaySummary"` AISummary string `json:"aiSummary"` Completed []string `json:"completed"` Pending []string `json:"pending"` FilePath string `json:"filePath"` ForkFrom string `json:"forkFrom"` } func (e *cacheEntry) ToSession(id string) *Session { return &Session{ ID: id, CustomTitle: e.CustomTitle, Cwd: e.Cwd, StartTime: e.StartTime, EndTime: e.EndTime, MsgCount: e.MsgCount, FirstMsg: e.FirstMsg, AwaySummary: e.AwaySummary, AISummary: e.AISummary, Completed: e.Completed, Pending: e.Pending, FilePath: e.FilePath, ForkFrom: e.ForkFrom, } } func cacheEntryFrom(s *Session, modTime time.Time) *cacheEntry { return &cacheEntry{ ModTime: modTime, CustomTitle: s.CustomTitle, Cwd: s.Cwd, StartTime: s.StartTime, EndTime: s.EndTime, MsgCount: s.MsgCount, FirstMsg: s.FirstMsg, AwaySummary: s.AwaySummary, AISummary: s.AISummary, Completed: s.Completed, Pending: s.Pending, FilePath: s.FilePath, ForkFrom: s.ForkFrom, } } func cachePath(home string) string { return filepath.Join(home, ".u-tabs", "session-cache.json") } func loadCache(home string) map[string]*cacheEntry { c, _ := loadCacheWithTimes(home) return c } func loadCacheWithTimes(home string) (map[string]*cacheEntry, map[string]time.Time) { data, err := os.ReadFile(cachePath(home)) if err != nil { return make(map[string]*cacheEntry), make(map[string]time.Time) } var cf cacheFile if json.Unmarshal(data, &cf) != nil || cf.Version != cacheVersion { return make(map[string]*cacheEntry), make(map[string]time.Time) } projTimes := cf.ProjectTimes if projTimes == nil { projTimes = make(map[string]time.Time) } return cf.Sessions, projTimes } func saveCache(home string, m map[string]*cacheEntry) { saveCacheWithTimes(home, m, nil) } func saveCacheWithTimes(home string, m map[string]*cacheEntry, projTimes map[string]time.Time) { cf := cacheFile{Version: cacheVersion, Sessions: m, ProjectTimes: projTimes} data, err := json.Marshal(cf) if err != nil { return } dir := filepath.Join(home, ".u-tabs") if err := os.MkdirAll(dir, 0o755); err != nil { log.Printf("[u-tabs] cache mkdir fail: %v", err) return } if err := os.WriteFile(cachePath(home), data, 0o644); err != nil { log.Printf("[u-tabs] cache write fail: %v", err) } } func truncStr(s string, maxRunes int) string { runes := []rune(s) if len(runes) <= maxRunes { return s } return string(runes[:maxRunes]) + "..." } // --- AI 摘要生成 --- type SummaryResultMsg struct { SessionID string Summary string Completed []string Pending []string } func generateSummaryCmd(filePath, sessionID string) tea.Cmd { return func() tea.Msg { sum, done, todo := generateAISummary(filePath) return SummaryResultMsg{SessionID: sessionID, Summary: sum, Completed: done, Pending: todo} } } type aiSummaryResult struct { Summary string `json:"summary"` Completed []string `json:"completed"` Pending []string `json:"pending"` } func generateAISummary(filePath string) (string, []string, []string) { messages := extractMessagesForSummary(filePath) if messages == "" { return "", nil, nil } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() prompt := `分析以下Claude Code对话,用JSON输出: {"summary":"20字内中文概括","completed":["已完成项1","已完成项2"],"pending":["待办项1"]} 要求:summary 20字内概括;completed 已完成工作(每项≤30字);pending 未完成/待办(每项≤30字)。只输出JSON。` + "\n\n" + messages cmd := exec.CommandContext(ctx, "claude", "-p", prompt) output, err := cmd.Output() if err != nil { return "", nil, nil } raw := strings.TrimSpace(string(output)) raw = strings.Trim(raw, "`") raw = strings.TrimPrefix(raw, "json") raw = strings.TrimSpace(raw) var result aiSummaryResult if json.Unmarshal([]byte(raw), &result) != nil { // Fallback: treat whole output as summary s := strings.Trim(raw, "\"'`*") s = strings.TrimPrefix(s, "概括:") s = strings.TrimPrefix(s, "主题:") if len(s) > 60 { s = truncStr(s, 60) } return s, nil, nil } if len(result.Summary) > 60 { result.Summary = truncStr(result.Summary, 60) } return result.Summary, result.Completed, result.Pending } func extractMessagesForSummary(path string) string { f, err := os.Open(path) if err != nil { return "" } defer f.Close() var msgs []string scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) userCount := 0 for scanner.Scan() { if userCount >= 5 { break } line := scanner.Bytes() if !hasType(line) { continue } if matchType(line, "user") { if msg := extractUserMsg(line); msg != "" { msgs = append(msgs, "用户: "+msg) userCount++ } } } return strings.Join(msgs, "\n") } func updateSummaryInCache(cache map[string]*cacheEntry, sessionID, summary string, completed, pending []string) { if cache == nil { return } entry, ok := cache[sessionID] if !ok { return } entry.AISummary = summary entry.Completed = completed entry.Pending = pending // 异步持久化 home, err := os.UserHomeDir() if err != nil { return } saveCache(home, cache) } // --- 收藏 --- func favoritesPath(home string) string { return filepath.Join(home, ".u-tabs", "favorites.json") } func loadFavorites() map[string]bool { home, err := os.UserHomeDir() if err != nil { return make(map[string]bool) } data, err := os.ReadFile(favoritesPath(home)) if err != nil { return make(map[string]bool) } var ids []string if json.Unmarshal(data, &ids) != nil { return make(map[string]bool) } m := make(map[string]bool, len(ids)) for _, id := range ids { m[id] = true } return m } func saveFavorites(favs map[string]bool) { home, err := os.UserHomeDir() if err != nil { return } ids := make([]string, 0, len(favs)) for id := range favs { ids = append(ids, id) } data, err := json.Marshal(ids) if err != nil { return } dir := filepath.Join(home, ".u-tabs") if err := os.MkdirAll(dir, 0o755); err != nil { log.Printf("[u-tabs] favorites mkdir fail: %v", err) return } if err := os.WriteFile(favoritesPath(home), data, 0o644); err != nil { log.Printf("[u-tabs] favorites write fail: %v", err) } } func countFavorites(projects []*ProjectDir, favs map[string]bool) int { n := 0 for _, pd := range projects { for _, s := range pd.Sessions { if favs[s.ID] { n++ } } } return n } func collectFavorites(projects []*ProjectDir, favs map[string]bool) []*Session { var out []*Session for _, pd := range projects { for _, s := range pd.Sessions { if favs[s.ID] { out = append(out, s) } } } return out } // --- Fork 记录 --- func forksPath(home string) string { return filepath.Join(home, ".u-tabs", "forks.json") } func loadForks() map[string]string { home, err := os.UserHomeDir() if err != nil { return make(map[string]string) } data, err := os.ReadFile(forksPath(home)) if err != nil { return make(map[string]string) } var m map[string]string if json.Unmarshal(data, &m) != nil { return make(map[string]string) } return m } func recordFork(targetID, sourceID string) { home, err := os.UserHomeDir() if err != nil { return } forks := loadForks() forks[targetID] = sourceID data, err := json.Marshal(forks) if err != nil { return } dir := filepath.Join(home, ".u-tabs") os.MkdirAll(dir, 0o755) os.WriteFile(forksPath(home), data, 0o644) }