From 3e81d4510fd30f506a747737f5cdd89492ed6112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 16 May 2026 23:18:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20HISTORY=20=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E4=BC=9A=E8=AF=9D=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三栏布局(目录/会话/详情), 扫描 ~/.claude/projects JSONL, 支持 resume 会话, 缓存增量更新, 代码审查问题修复 --- internal/app.go | 524 +++++++++++++++++++++++++++++++++------- internal/history.go | 406 +++++++++++++++++++++++++++++++ internal/style/style.go | 20 +- 3 files changed, 862 insertions(+), 88 deletions(-) create mode 100644 internal/history.go diff --git a/internal/app.go b/internal/app.go index ce580ea..6048cd2 100644 --- a/internal/app.go +++ b/internal/app.go @@ -26,6 +26,7 @@ type Model struct { width int height int launched string + history HistoryState } func NewModel() *Model { @@ -39,51 +40,190 @@ func (m *Model) Init() tea.Cmd { return nil } +// totalTabs 包含 HISTORY 的总 Tab 数 +func (m *Model) totalTabs() int { + return len(Groups) + 1 +} + func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: - s := msg.String() - switch s { - case "q", "ctrl+c": - return m, tea.Quit - case "tab", "right", "l": - m.activeGroup = (m.activeGroup + 1) % len(Groups) - m.cursor = 0 - m.inputBuf = "" - case "shift+tab", "left", "h": - m.activeGroup = (m.activeGroup - 1 + len(Groups)) % len(Groups) - m.cursor = 0 - m.inputBuf = "" - case "1", "2", "3", "4": - idx, _ := strconv.Atoi(s) - if idx <= len(Groups) { - m.activeGroup = idx - 1 - m.cursor = 0 - m.inputBuf = "" - } - case "up", "k": - m.moveCursor(-1) - case "down", "j": - m.moveCursor(1) - case " ": - m.toggleMultiSelect() - case "enter": - if m.inputBuf != "" { - return m.launchByInput() - } - return m.launchSelected() - default: - if len(s) == 1 && s[0] >= '0' && s[0] <= '9' { - m.inputBuf += s - } - } + return m.handleKey(msg.String()) case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + case ScanCompleteMsg: + m.history.Projects = msg.Projects + m.history.Loaded = true + m.history.Scanning = false + return m, nil } return m, nil } +// --- 按键分发 --- + +func (m *Model) handleKey(s string) (*Model, tea.Cmd) { + // 全局按键 + switch s { + case "q", "ctrl+c": + return m, tea.Quit + } + + // 数字键跳转 Tab + if len(s) == 1 && s[0] >= '1' && s[0] <= '9' { + idx, _ := strconv.Atoi(s) + if idx <= m.totalTabs() { + m.activeGroup = idx - 1 + m.cursor = 0 + m.inputBuf = "" + return m, m.onTabSwitch() + } + } + + // HISTORY Tab: tab/left/right 用于面板切换 + if m.IsHistoryTab() { + return m.handleHistoryKey(s) + } + + // Workspace Tab 按键 + switch s { + case "tab", "right", "l": + m.activeGroup = (m.activeGroup + 1) % m.totalTabs() + m.cursor = 0 + m.inputBuf = "" + return m, m.onTabSwitch() + case "shift+tab", "left", "h": + m.activeGroup = (m.activeGroup - 1 + m.totalTabs()) % m.totalTabs() + m.cursor = 0 + m.inputBuf = "" + return m, m.onTabSwitch() + } + + return m.handleWorkspaceKey(s) +} + +func (m *Model) onTabSwitch() tea.Cmd { + if m.IsHistoryTab() && !m.history.Loaded && !m.history.Scanning { + m.history.Scanning = true + return ScanSessionsCmd() + } + return nil +} + +// --- Workspace Tab 按键 --- + +func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) { + switch s { + case "up", "k": + m.moveCursor(-1) + case "down", "j": + m.moveCursor(1) + case " ": + m.toggleMultiSelect() + case "enter": + if m.inputBuf != "" { + return m.launchByInput() + } + return m.launchSelected() + default: + if len(s) == 1 && s[0] >= '0' && s[0] <= '9' { + m.inputBuf += s + } + } + return m, nil +} + +// --- HISTORY Tab 按键 --- + +func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) { + switch s { + case "up", "k": + if m.history.FocusPanel == 0 { + m.moveHistoryDir(-1) + } else { + m.moveHistorySess(-1) + } + case "down", "j": + if m.history.FocusPanel == 0 { + m.moveHistoryDir(1) + } else { + m.moveHistorySess(1) + } + case "tab", "right", "l": + if m.history.FocusPanel < 1 { + m.history.FocusPanel = 1 + } + case "shift+tab", "left", "h": + if m.history.FocusPanel > 0 { + m.history.FocusPanel = 0 + } + case "enter": + if m.history.FocusPanel == 0 { + // 在左栏按 enter 切到中栏 + m.history.FocusPanel = 1 + } else { + return m.resumeSelected() + } + } + return m, nil +} + +func (m *Model) moveHistoryDir(dir int) { + if len(m.history.Projects) == 0 { + return + } + m.history.DirCursor += dir + if m.history.DirCursor < 0 { + m.history.DirCursor = len(m.history.Projects) - 1 + } + if m.history.DirCursor >= len(m.history.Projects) { + m.history.DirCursor = 0 + } + m.history.SessCursor = 0 +} + +func (m *Model) moveHistorySess(dir int) { + sessions := m.currentSessions() + if len(sessions) == 0 { + return + } + m.history.SessCursor += dir + if m.history.SessCursor < 0 { + m.history.SessCursor = len(sessions) - 1 + } + if m.history.SessCursor >= len(sessions) { + m.history.SessCursor = 0 + } +} + +func (m *Model) currentSessions() []*Session { + if m.history.DirCursor < len(m.history.Projects) { + return m.history.Projects[m.history.DirCursor].Sessions + } + return nil +} + +func (m *Model) currentSession() *Session { + sessions := m.currentSessions() + if len(sessions) == 0 || m.history.SessCursor >= len(sessions) { + return nil + } + return sessions[m.history.SessCursor] +} + +func (m *Model) resumeSelected() (*Model, tea.Cmd) { + s := m.currentSession() + if s == nil { + return m, nil + } + go resumeSession(s) + m.launched = "resume: " + s.CustomTitle + return m, nil +} + +// --- Workspace 光标 --- + func (m *Model) moveCursor(dir int) { svcs := WorkspacesByGroup(Groups[m.activeGroup].Label) if len(svcs) == 0 { @@ -146,6 +286,7 @@ func (m *Model) launchByInput() (*Model, tea.Cmd) { return m, nil } +// --- View --- func (m *Model) View() tea.View { v := tea.NewView(m.render()) @@ -162,13 +303,11 @@ func (m *Model) render() string { var b strings.Builder - // layout widths - listW := max(42, min(65, m.width*55/100)) - detailW := max(30, m.width-listW-3) - // ── header: title + tabs ── b.WriteString(style.TitleStyle.Render(" u-tabs ")) sep := style.TabSep.Render(" | ") + + // Workspace Tabs for i, g := range Groups { if i > 0 { b.WriteString(sep) @@ -187,19 +326,83 @@ func (m *Model) render() string { b.WriteString(style.TabInactiveStyle.Render(label)) } } + + // HISTORY Tab + b.WriteString(sep) + histLabel := " HISTORY 历史 " + if m.IsHistoryTab() { + gs := style.GroupStyles["HISTORY"] + b.WriteString(lipgloss.NewStyle(). + Bold(true).Background(style.BgPanel).Foreground(gs.GetForeground()). + Padding(0, 1).Render(histLabel)) + } else { + b.WriteString(style.TabInactiveStyle.Render(histLabel)) + } + b.WriteString("\n") b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", m.width))) b.WriteString("\n") + // ── 内容区 ── + if m.IsHistoryTab() { + b.WriteString(m.renderHistory()) + } else { + b.WriteString(m.renderWorkspace()) + } + + // ── footer ── + b.WriteString("\n") + if m.inputBuf != "" { + b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ▶ num:%s [Enter]go [Esc]cancel", m.inputBuf))) + } + if m.launched != "" { + b.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Render(fmt.Sprintf(" ✓ %s", m.launched))) + m.launched = "" + } + b.WriteString("\n") + + helpParts := m.renderHelp() + b.WriteString(" " + strings.Join(helpParts, " ")) + + if hint := ConfigHint(); hint != "" { + b.WriteString("\n" + hint) + } + return b.String() +} + +func (m *Model) renderHelp() []string { + if m.IsHistoryTab() { + return []string{ + m.fmtHelp("j/k", "sel"), + m.fmtHelp("Tab", "→panel"), + m.fmtHelp("Enter", "resume"), + m.fmtHelp("1-"+fmt.Sprintf("%d", m.totalTabs()), "tab"), + m.fmtHelp("q", "quit"), + } + } + return []string{ + m.fmtHelp("j/k", "sel"), + m.fmtHelp("Enter", "run"), + m.fmtHelp("Space", "multi"), + m.fmtHelp("Tab", "group"), + m.fmtHelp("q", "quit"), + } +} + +// --- Workspace 渲染 --- + +func (m *Model) renderWorkspace() string { + listW := max(42, min(65, m.width*55/100)) + detailW := max(30, m.width-listW-3) + g := Groups[m.activeGroup] gs, _ := style.GroupStyles[g.Label] svcs := WorkspacesByGroup(g.Label) if len(svcs) == 0 { - b.WriteString(style.SubtitleStyle.Render(" empty")) - return b.String() + return style.SubtitleStyle.Render(" empty") } - // ═══ left: list ═══ + // 左栏: 列表 var left strings.Builder innerW := listW - 4 for i, ws := range svcs { @@ -209,19 +412,26 @@ func (m *Model) render() string { } mark := " " if m.selected[ws.Index] { - mark = style.MarkStyle.Render("✓") + mark = "✓" } - num := style.NumStyle.Render(fmt.Sprintf("%02d", ws.N)) - prefix := cur + " " + mark + " " + num + " " - remainW := max(10, innerW-lipgloss.Width(prefix)) - text := truncateByWidth(ws.Title+" "+ws.Prompt, remainW) - - line := prefix + text if i == m.cursor { - left.WriteString(style.SelStyle.Width(innerW).Render(line)) + // 选中行:纯文本,让 SelStyle 统一着色 + prefix := cur + " " + mark + " " + fmt.Sprintf("%02d", ws.N) + " " + remainW := max(10, innerW-len(prefix)) + text := truncateByWidth(ws.Title+" "+ws.Prompt, remainW) + left.WriteString(style.SelStyle.Width(innerW).Render(prefix + text)) } else { - left.WriteString(style.NormStyle.Render(line)) + // 非选中行:子样式着色 + markStr := " " + if m.selected[ws.Index] { + markStr = style.MarkStyle.Render("✓") + } + num := style.NumStyle.Render(fmt.Sprintf("%02d", ws.N)) + prefix := cur + " " + markStr + " " + num + " " + remainW := max(10, innerW-lipgloss.Width(prefix)) + text := truncateByWidth(ws.Title+" "+ws.Prompt, remainW) + left.WriteString(style.NormStyle.Render(prefix + text)) } left.WriteString("\n") } @@ -234,7 +444,7 @@ func (m *Model) render() string { Padding(0, 1). Render(groupHeader + "\n" + left.String()) - // ═══ right: detail ═══ + // 右栏: 详情 var right strings.Builder if m.cursor < len(svcs) { ws := svcs[m.cursor] @@ -279,34 +489,165 @@ func (m *Model) render() string { Padding(0, 1). Render(right.String()) - b.WriteString(lipgloss.JoinHorizontal(lipgloss.Left, listBox, " ", detailBox)) - - // ── footer ── - b.WriteString("\n") - if m.inputBuf != "" { - b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ▶ num:%s [Enter]go [Esc]cancel", m.inputBuf))) - } - if m.launched != "" { - b.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Render(fmt.Sprintf(" ✓ %s", m.launched))) - m.launched = "" - } - b.WriteString("\n") - - helpParts := []string{ - m.fmtHelp("j/k", "sel"), - m.fmtHelp("Enter", "run"), - m.fmtHelp("Space", "multi"), - m.fmtHelp("Tab", "group"), - m.fmtHelp("q", "quit"), - } - b.WriteString(" " + strings.Join(helpParts, " ")) - - if hint := ConfigHint(); hint != "" { - b.WriteString("\n" + hint) - } - return b.String() + return lipgloss.JoinHorizontal(lipgloss.Left, listBox, " ", detailBox) } +// --- HISTORY 三栏渲染 --- + +func (m *Model) renderHistory() string { + if m.history.Scanning { + return style.ScanningStyle.Render(" scanning sessions...") + } + if !m.history.Loaded { + return style.ScanningStyle.Render(" press Tab to load sessions...") + } + if len(m.history.Projects) == 0 { + return style.SubtitleStyle.Render(" no sessions found") + } + + // 三栏宽度分配 + avail := m.width - 4 // borders + gaps + dirW := max(20, avail*20/100) + sessW := max(30, avail*40/100) + detailW := max(20, avail-dirW-sessW) + + // 左栏: 目录列表 + dirBox := m.renderDirPanel(dirW) + // 中栏: 会话列表 + sessBox := m.renderSessPanel(sessW) + // 右栏: 详情 + detailBox := m.renderHistoryDetail(detailW) + + return lipgloss.JoinHorizontal(lipgloss.Left, dirBox, " ", sessBox, " ", detailBox) +} + +func (m *Model) renderDirPanel(w int) string { + var b strings.Builder + innerW := w - 4 + + b.WriteString(style.DetailTitle.Render(" dirs ")) + b.WriteString("\n") + + for i, pd := range m.history.Projects { + cur := " " + if i == m.history.DirCursor { + cur = "▸" + } + name := truncateByWidth(pd.DirShort, max(8, innerW-8)) + + if i == m.history.DirCursor && m.history.FocusPanel == 0 { + line := fmt.Sprintf("%s %s (%d)", cur, name, len(pd.Sessions)) + b.WriteString(style.SelStyle.Width(innerW).Render(line)) + } else { + cnt := style.DirCountStyle.Render(fmt.Sprintf("(%d)", len(pd.Sessions))) + line := fmt.Sprintf("%s %s %s", cur, name, cnt) + b.WriteString(style.NormStyle.Render(line)) + } + b.WriteString("\n") + } + + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(style.BgPanel). + Width(w). + Padding(0, 1). + Render(b.String()) +} + +func (m *Model) renderSessPanel(w int) string { + var b strings.Builder + innerW := w - 4 + + b.WriteString(style.DetailTitle.Render(" sessions ")) + b.WriteString("\n") + + sessions := m.currentSessions() + if len(sessions) == 0 { + b.WriteString(style.SubtitleStyle.Render(" no sessions")) + } else { + for i, s := range sessions { + cur := " " + if i == m.history.SessCursor { + cur = "▸" + } + timeStr := s.StartTime.Format("01-02 15:04") + title := s.DisplayTitle() + remainW := max(10, innerW-10) + text := truncateByWidth(title, remainW) + + if i == m.history.SessCursor && m.history.FocusPanel == 1 { + line := fmt.Sprintf("%s %s %s", cur, timeStr, text) + b.WriteString(style.SelStyle.Width(innerW).Render(line)) + } else { + line := fmt.Sprintf("%s %s %s", cur, style.SessionTimeStyle.Render(timeStr), text) + b.WriteString(style.NormStyle.Render(line)) + } + + b.WriteString("\n") + } + } + + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(style.BgPanel). + Width(w). + Padding(0, 1). + Render(b.String()) +} + +func (m *Model) renderHistoryDetail(w int) string { + var b strings.Builder + + s := m.currentSession() + if s == nil { + b.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" ← select to view")) + } else { + b.WriteString(style.DetailTitle.Render(" detail ")) + b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", w-12))) + b.WriteString("\n\n") + + rows := []struct { + key string + val string + sty lipgloss.Style + }{ + {"title", s.CustomTitle, style.ValStyle}, + {"dir", s.Cwd, style.ValStyle}, + {"time", fmt.Sprintf("%s ~ %s", s.StartTime.Format("01-02 15:04"), s.EndTime.Format("15:04")), style.NumStyle}, + {"msgs", fmt.Sprintf("%d", s.MsgCount), style.SessionMsgCntStyle}, + } + for _, r := range rows { + b.WriteString(" ") + b.WriteString(style.KeyStyle.Render(r.key)) + b.WriteString(" ") + b.WriteString(r.sty.Render(r.val)) + b.WriteString("\n") + } + + // 摘要 + summary := s.DisplaySummary() + if summary != "" { + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(" " + strings.Repeat("─", w-6))) + b.WriteString("\n") + b.WriteString(style.SessionSummaryStyle.Render(" " + truncateByWidth(summary, w-6))) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" [Enter]resume")) + } + + return lipgloss.NewStyle(). + Border(lipgloss.NormalBorder()). + BorderForeground(style.BgPanel). + Width(w). + Padding(0, 1). + Render(b.String()) +} + +// --- 工具 --- + func (m *Model) fmtHelp(key, desc string) string { return lipgloss.NewStyle().Foreground(style.Accent).Render(key) + lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc) @@ -346,22 +687,39 @@ func runeWidth(r rune) int { return 1 } -// --- launch --- +// --- 启动 --- -func launchWorkspace(ws Workspace) { - script := buildLaunchScript(ws) +func launchInWt(dir, script string) { encoded := encodePSCommand(script) color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255)) cmd := exec.Command("wt.exe", "-w", "0", - "-d", ws.Dir, + "-d", dir, "--tabColor", color, "pwsh", "-NoExit", "-EncodedCommand", encoded, ) if err := cmd.Start(); err != nil { - log.Printf("[u-tabs] launch fail %s(%d): %v", ws.Title, ws.N, err) + log.Printf("[u-tabs] launch fail: %v", err) } } +func launchWorkspace(ws Workspace) { + script := buildLaunchScript(ws) + launchInWt(ws.Dir, script) +} + +func resumeSession(s *Session) { + cwd := strings.ReplaceAll(s.Cwd, "/", "\\") + title := s.CustomTitle + if title == "" { + title = s.ID[:8] + } + script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "resume: %s" +Write-Host "=== Resuming: %s ===" -ForegroundColor Cyan +cd "%s" +claude -r %s`, title, title, cwd, s.ID) + launchInWt(cwd, script) +} + func buildLaunchScript(ws Workspace) string { return fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s" Write-Host "=== %s ===" -ForegroundColor Cyan diff --git a/internal/history.go b/internal/history.go new file mode 100644 index 0000000..0ba4b54 --- /dev/null +++ b/internal/history.go @@ -0,0 +1,406 @@ +package internal + +import ( + "bufio" + "bytes" + "encoding/json" + "log" + "os" + "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 生成的摘要 (缓存) +} + +// ProjectDir 按目录分组的会话集合 +type ProjectDir struct { + Dir string // 完整路径 + DirShort string // 显示名 (路径最后一段) + Sessions []*Session // 按时间倒序 +} + +// DisplayTitle 返回会话显示标题 +func (s *Session) DisplayTitle() 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 { + if s.AwaySummary != "" { + return s.AwaySummary + } + return s.FirstMsg +} + +// HistoryState HISTORY Tab 视图状态 +type HistoryState struct { + Projects []*ProjectDir + DirCursor int // 左栏光标 + SessCursor int // 中栏光标 + FocusPanel int // 0=左栏 1=中栏 + Loaded bool + Scanning bool +} + +// IsHistoryTab 判断当前是否在 HISTORY Tab +func (m *Model) IsHistoryTab() bool { + return m.activeGroup == len(Groups) +} + +// --- 扫描 --- + +type ScanCompleteMsg struct { + Projects []*ProjectDir +} + +func ScanSessionsCmd() tea.Cmd { + return func() tea.Msg { + return ScanCompleteMsg{Projects: scanAllProjects()} + } +} + +func scanAllProjects() []*ProjectDir { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + projectsDir := filepath.Join(home, ".claude", "projects") + entries, err := os.ReadDir(projectsDir) + if err != nil { + return nil + } + + cache := loadCache(home) + dirMap := make(map[string]*ProjectDir) + + 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 + } + 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 +} + +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} + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + lineCount := 0 + firstMsgFound := false + scannedLines := 0 // 有效行(含 type 的) + + for scanner.Scan() { + lineCount++ + line := scanner.Bytes() + + if !hasType(line) { + continue + } + scannedLines++ + + // 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 + } + } + + // 按 scannedLines/lineCount 比例估算总消息数 + if lineCount > scanLineLimit && scannedLines > 0 { + ratio := float64(lineCount) / float64(scanLineLimit) + s.MsgCount = int(float64(s.MsgCount) * ratio) + } + + 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+6:], &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 = 1 + +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"` +} + +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, + } +} + +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, + } +} + +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]) + "..." +} diff --git a/internal/style/style.go b/internal/style/style.go index 0abbf30..a63e597 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -42,9 +42,19 @@ var ( ) var GroupStyles = map[string]lipgloss.Style{ - "CORE": lipgloss.NewStyle().Bold(true).Foreground(Red), - "LAB": lipgloss.NewStyle().Bold(true).Foreground(Success), - "TOOLS": lipgloss.NewStyle().Bold(true).Foreground(Warning), - "ME": lipgloss.NewStyle().Bold(true).Foreground(Purple), - "TEMP": lipgloss.NewStyle().Bold(true).Foreground(Cyan), + "CORE": lipgloss.NewStyle().Bold(true).Foreground(Red), + "LAB": lipgloss.NewStyle().Bold(true).Foreground(Success), + "TOOLS": lipgloss.NewStyle().Bold(true).Foreground(Warning), + "ME": lipgloss.NewStyle().Bold(true).Foreground(Purple), + "TEMP": lipgloss.NewStyle().Bold(true).Foreground(Cyan), + "HISTORY": lipgloss.NewStyle().Bold(true).Foreground(Cyan), } + +// --- 历史会话专用样式 --- +var ( + SessionTimeStyle = lipgloss.NewStyle().Foreground(Dim).Width(12).Inline(true) + SessionMsgCntStyle = lipgloss.NewStyle().Foreground(Accent).Inline(true) + SessionSummaryStyle = lipgloss.NewStyle().Foreground(Dim).Italic(true).Inline(true) + DirCountStyle = lipgloss.NewStyle().Foreground(Accent).Inline(true) + ScanningStyle = lipgloss.NewStyle().Foreground(Warning).Bold(true) +)