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 文件完整路径 } // 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 } // IsHistoryTab 判断当前是否在 HISTORY Tab func (m *Model) IsHistoryTab() bool { return m.activeGroup == len(Groups) } // --- 扫描 --- type ScanCompleteMsg struct { Projects []*ProjectDir UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要 } func ScanSessionsCmd() tea.Cmd { return func() tea.Msg { projects, updated := scanAllProjects() return ScanCompleteMsg{Projects: projects, UpdatedIDs: updated} } } func scanAllProjects() ([]*ProjectDir, map[string]bool) { home, err := os.UserHomeDir() if err != nil { return nil, nil } projectsDir := filepath.Join(home, ".claude", "projects") entries, err := os.ReadDir(projectsDir) if err != nil { return nil, nil } cache := loadCache(home) dirMap := make(map[string]*ProjectDir) updatedIDs := make(map[string]bool) for _, entry := range entries { if !entry.IsDir() { continue } projectPath := filepath.Join(projectsDir, entry.Name()) 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") filePath := filepath.Join(projectPath, f.Name()) info, err := f.Info() if err != nil { continue } if cached, ok := cache[sessionID]; ok && cached.ModTime.Equal(info.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, info.ModTime()) } } saveCache(home, cache) 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, updatedIDs } 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"` } 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"` } 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, } } 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, } } func cachePath(home string) string { return filepath.Join(home, ".u-tabs", "session-cache.json") } func loadCache(home string) map[string]*cacheEntry { data, err := os.ReadFile(cachePath(home)) if err != nil { return make(map[string]*cacheEntry) } var cf cacheFile if json.Unmarshal(data, &cf) != nil || cf.Version != cacheVersion { return make(map[string]*cacheEntry) } return cf.Sessions } func saveCache(home string, m map[string]*cacheEntry) { cf := cacheFile{Version: cacheVersion, Sessions: m} 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 saveSummaryToCache(sessionID, summary string, completed, pending []string) { home, err := os.UserHomeDir() if err != nil { return } cache := loadCache(home) entry, ok := cache[sessionID] if !ok { return } entry.AISummary = summary entry.Completed = completed entry.Pending = pending 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 }