From 95f349bcd7d64ffe8e1955901db8f94b3100fd7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 31 Jul 2026 21:22:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=A0=87=E9=A2=98=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E4=B8=8E=E6=91=98=E8=A6=81=E8=B4=A8=E9=87=8F?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 标题自定义编辑:HISTORY 详情按 e 编辑标题,UserTitle 持久化到缓存且最高优先级 - AI 摘要质量修复:困惑回复检测词表收紧,消息提取过滤噪音,修复 JSON 包裹困惑回复漏网 - 数据维护命令:--fix-titles 清理低质量摘要,--regen-summaries 并发生成 - 修复代码审查 9 个问题:退格 UTF-8 损坏、中文输入、flush 调度、误杀合法摘要、 按键全局拦截、UserTitle 未同步窗口标题、wt 启动回落、cmd 注入防护、前缀误隐藏会话 - 启动链 wt.exe 回退与 cmd start 标题转义 - gofmt 规范化,新增 launch_test.go --- internal/app.go | 69 +- internal/history.go | 1328 ++++++++++++++++++++++++++++++++++++--- internal/key.go | 43 ++ internal/launch.go | 83 ++- internal/launch_test.go | 22 + internal/style/style.go | 6 +- internal/update.go | 10 +- internal/view.go | 1 + internal/view_hist.go | 13 +- main.go | 16 + 10 files changed, 1465 insertions(+), 126 deletions(-) create mode 100644 internal/launch_test.go diff --git a/internal/app.go b/internal/app.go index 5f66251..e8c7723 100644 --- a/internal/app.go +++ b/internal/app.go @@ -3,6 +3,7 @@ package internal import ( "fmt" "os" + "strings" "time" tea "charm.land/bubbletea/v2" @@ -10,20 +11,22 @@ import ( // Model 主模型 type Model struct { - activeGroup int - cursor int - selected map[int]bool - inputBuf string - width int - height int - launched string - history HistoryState - pendingCmd string - update UpdateState - wsFocus int // workspace 焦点列: 0=tabs 1=list - wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口) - forkMode bool // 分叉输入模式 - forkBuf string // 分叉方向提示输入 + activeGroup int + cursor int + selected map[int]bool + inputBuf string + width int + height int + launched string + history HistoryState + pendingCmd string + update UpdateState + wsFocus int // workspace 焦点列: 0=tabs 1=list + wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口) + forkMode bool // 分叉输入模式 + forkBuf string // 分叉方向提示输入 + editTitleMode bool // 编辑标题模式 + editTitleBuf string // 标题编辑缓冲区 // 性能缓存 allSessionsCache []*Session // "全部" tab 会话切片缓存 @@ -88,10 +91,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { total += len(pd.Sessions) } m.launched = fmt.Sprintf("refreshed: %d projects, %d sessions", len(msg.Projects), total) - if len(msg.UpdatedIDs) > 0 { - return m, m.nextSummaryCmd() - } - return m, nil + // 无论本次是否有文件更新,都尝试补齐缺失的摘要 + return m, m.nextSummaryCmd() case SummaryResultMsg: m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending) m.cacheDirty = true @@ -133,6 +134,38 @@ func (m *Model) GetPendingCmd() string { return m.pendingCmd } +func (m *Model) confirmEditTitle() (*Model, tea.Cmd) { + s := m.currentSession() + title := strings.TrimSpace(m.editTitleBuf) + m.editTitleMode = false + m.editTitleBuf = "" + if s == nil || title == "" { + return m, nil + } + cmd := m.setUserTitle(s.ID, title) + m.launched = "rename: " + title + return m, cmd +} + +func (m *Model) setUserTitle(sessionID, title string) tea.Cmd { + for _, pd := range m.history.Projects { + for _, s := range pd.Sessions { + if s.ID == sessionID { + s.UserTitle = title + break + } + } + } + if m.history.Cache != nil { + if entry, ok := m.history.Cache[sessionID]; ok { + entry.UserTitle = title + m.cacheDirty = true + } + } + // 调度落盘,避免改名只存在内存、被磁盘旧缓存覆盖 + return cacheFlushTick() +} + func (m *Model) flushCache() { if !m.cacheDirty || m.history.Cache == nil { return diff --git a/internal/history.go b/internal/history.go index 53dd715..3e633cc 100644 --- a/internal/history.go +++ b/internal/history.go @@ -2,411 +2,780 @@ package internal import ( "bufio" + "bytes" + "context" + "encoding/json" + "log" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "sync" + "time" tea "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(空表示非分叉) + 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(空表示非分叉) + + UserTitle string // 用户手动编辑的自定义标题(最高优先级,持久化到缓存) + + UserMsgs []string // 全部用户消息(正序),详情面板懒加载,不持久化 + + userMsgsLoaded bool // 历史消息是否已懒加载,避免空结果反复读盘 + } // ProjectDir 按目录分组的会话集合 + type ProjectDir struct { - Dir string // 完整路径 - DirShort string // 显示名 (路径最后一段) + Dir string // 完整路径 + + DirShort string // 显示名 (路径最后一段) + Sessions []*Session // 按时间倒序 + } // DisplayTitle 返回会话显示标题 + func (s *Session) DisplayTitle() string { + return cleanText(s.displayTitleRaw()) + } func (s *Session) displayTitleRaw() string { + + if s.UserTitle != "" { + + return s.UserTitle + + } + 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 // 左栏光标 + Projects []*ProjectDir + + DirCursor int // 左栏光标 + SessCursor int // 中栏光标 + FocusPanel int // 0=左栏 1=中栏 - Loaded bool - Scanning bool - Favorites map[string]bool - Cache map[string]*cacheEntry // 内存缓存,避免重复 I/O + + 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 变化的会话,需要重新生成摘要 + 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) + scanBuf := make([]byte, 0, 64*1024) // 共享扫描缓冲区 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, &scanBuf) + if session == nil || session.Cwd == "" { + continue + } + // 清除旧摘要,标记需要重新生成 + session.AISummary = "" + session.Completed = nil + session.Pending = nil + + // 保留旧缓存中的用户自定义标题 + + if cached, ok := cache[sessionID]; ok && cached.UserTitle != "" { + + session.UserTitle = cached.UserTitle + + } + 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 +// summaryPromptPrefix u-tabs 调用 claude -p 生成会话摘要时使用的 prompt 前缀。 + +// 作为判定"摘要会话"的依据:首条 user 消息以此开头即为 -p 摘要会话,扫描时跳过。 + +// 旧版用"前20行含 queue-operation"判定,但新版交互会话也带 queue-operation,会误杀正常会话。 + +const summaryPromptPrefix = "分析以下Claude Code对话" + func scanSessionFile(path string, sessionID string, buf *[]byte) *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(*buf, 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 != "" { + + // 首条 user 消息是 u-tabs 的 AI 摘要 prompt → claude -p 摘要会话,跳过 + + // 仅当同时命中 prompt 特征片段才判定为 -p 摘要会话,避免真实会话首条恰以同样文案开头被误隐藏 + + if strings.HasPrefix(msg, summaryPromptPrefix) && + (strings.Contains(msg, "用JSON输出") || strings.Contains(msg, `{"summary"`) || strings.Contains(msg, "completed")) { + + return nil + + } + 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") { + + !isTrivialMsg(text) { + return text + } + } + } + return "" + +} + +// isTrivialMsg 判断是否为无意义的噪音消息(图片、恢复提示、系统指令等) + +func isTrivialMsg(s string) bool { + + trivialPrefixes := []string{ + + " 60 { - s = truncStr(s, 60) + + // 检测 Claude 因没拿到内容而回复的"没看到对话"等错误信息 + + if isConfusedResponse(raw) { + + return "", nil, 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 + + } + + // JSON 解析成功,但 summary 仍可能是 Claude 的困惑回复(被包成合法 JSON 返回) + + if isConfusedResponse(result.Summary) { + + return "", nil, nil + } if len(result.Summary) > 60 { + result.Summary = truncStr(result.Summary, 60) + } + return result.Summary, result.Completed, result.Pending + +} + +// isConfusedResponse 检测 claude -p 摘要调用中 Claude 回复"没有收到内容"等错误, + +// 此类回复不应作为标题使用,应降级为 FirstMsg。 + +// 两级判定:① 明确短语;② 启发式——回复复述了 prompt(含"对话内容"/"分析以下")且带否定词。 + +func isConfusedResponse(text string) bool { + + lower := strings.ToLower(text) + + // ① 明确的困惑短语 + + confused := []string{ + + "没有附上", "没有附带", "没有看到", "没有包含", "没有提供", "没有给出", + + "没有需要分析", "没有要分析", "没有实际", + + "消息是空的", "内容是空的", "冒号后面", "冒号后", + + "只到了", "就结束了", "缺少了", "请求似乎不完整", + + "只有系统提示", // 精确匹配"只有系统提示",避免裸"系统提示"误杀合法摘要 + + "nothing to analyze", "no conversation", "didn't receive", + } + + for _, p := range confused { + + if strings.Contains(lower, p) { + + return true + + } + + } + + // ② 启发式:复述 prompt 关键词 + 否定/请求词 → 判为困惑回复 + + echoesPrompt := strings.Contains(lower, "对话内容") || + + strings.Contains(lower, "分析以下") || + + strings.Contains(lower, "claude code 对话") || + + strings.Contains(lower, "claude code对话") + + if echoesPrompt { + + negations := []string{"没", "无", "缺", "空", "只有", "似乎"} + + for _, n := range negations { + + if strings.Contains(lower, n) { + + return true + + } + + } + + } + + return false + +} + +// isBadTitle 判断 AI 摘要是否为低质量(需重新生成): + +// Claude 困惑回复 / 噪音消息 / 过短(≤4 字符) + +func isBadTitle(s string) bool { + + s = strings.TrimSpace(s) + + if s == "" { + + return false + + } + + return isConfusedResponse(s) || isTrivialMsg(s) || len([]rune(s)) <= 3 + +} + +// CleanupBadTitles 清理缓存中的低质量 AI 摘要(Claude 困惑回复、噪音、过短)。 + +// 清空这些条目的 AISummary 并把 ModTime 置零、清空 projectTimes, + +// 使下次启动时这些会话被重新扫描并重新生成摘要。返回清理条目数。 + +func CleanupBadTitles() int { + + home, err := os.UserHomeDir() + + if err != nil { + + return 0 + + } + + cache, _ := loadCacheWithTimes(home) + + if len(cache) == 0 { + + return 0 + + } + + cleaned := 0 + + for _, e := range cache { + + if e.AISummary == "" { + + continue + + } + + if isBadTitle(e.AISummary) { + + e.AISummary = "" + + e.Completed = nil + + e.Pending = nil + + e.ModTime = time.Time{} // 置零 → 下次扫描判定为变更 → 重新生成 + + cleaned++ + + } + + } + + if cleaned == 0 { + + return 0 + + } + + // 清空 projectTimes:否则下次启动走 rebuildFromCache 分支(只看目录 modTime), + + // 不检查单文件 modTime,坏条目不会被重扫。清空后强制全量重扫目录。 + + saveCacheWithTimes(home, cache, make(map[string]time.Time)) + + return cleaned + +} + +// RegenerateSummaries 为缓存中所有 AISummary 为空的会话重新生成 AI 摘要。 + +// 并发调用 claude -p(concurrency 控制并发数),结果实时写回缓存并恢复真实 ModTime。 + +// 返回 (成功数, 降级数);提取不到有效内容的会话降级为 FirstMsg 兜底,不留空标题。 + +func RegenerateSummaries(concurrency int) (int, int) { + + home, err := os.UserHomeDir() + + if err != nil { + + return 0, 0 + + } + + cache, projTimes := loadCacheWithTimes(home) + + if len(cache) == 0 { + + return 0, 0 + + } + + if concurrency < 1 { + + concurrency = 4 + + } + + type job struct { + id string + + entry *cacheEntry + } + + var jobs []job + + for id, e := range cache { + + if e.AISummary == "" && e.FilePath != "" { + + jobs = append(jobs, job{id, e}) + + } + + } + + total := len(jobs) + + if total == 0 { + + return 0, 0 + + } + + log.Printf("[regen] 开始重新生成 %d 个摘要(并发 %d)", total, concurrency) + + var ( + mu sync.Mutex + + ok int + + fail int + + done int + + wg sync.WaitGroup + ) + + sem := make(chan struct{}, concurrency) + + for _, j := range jobs { + + wg.Add(1) + + go func(j job) { + + defer wg.Done() + + sem <- struct{}{} + + defer func() { <-sem }() + + sum, completed, pending := generateAISummary(j.entry.FilePath) + + mu.Lock() + + done++ + + if sum == "" { + + // 降级:从文件重新提取首条有效用户消息作为标题。 + + // cache 里的 FirstMsg 可能是旧版扫描逻辑(无噪音过滤)留下的脏数据, + + // 所以直接跳过它;极短文本(≤3 字符)也用 ID 兜底,避免循环清理。 + + fallback := firstValidUserMsg(j.entry.FilePath) + + if fallback == "" || isTrivialMsg(fallback) || len([]rune(fallback)) <= 3 { + + fallback = j.id[:8] + + } + + j.entry.AISummary = fallback + + fail++ + + log.Printf("[regen] [%d/%d] %s 降级 → %s", done, total, j.id[:8], truncStr(j.entry.AISummary, 30)) + + } else { + + j.entry.AISummary = sum + + j.entry.Completed = completed + + j.entry.Pending = pending + + ok++ + + log.Printf("[regen] [%d/%d] %s → %s", done, total, j.id[:8], truncStr(sum, 30)) + + } + + // 恢复真实 ModTime,避免下次启动重复重扫 + + if fi, err := os.Stat(j.entry.FilePath); err == nil { + + j.entry.ModTime = fi.ModTime() + + } + + mu.Unlock() + + }(j) + + } + + wg.Wait() + + saveCacheWithTimes(home, cache, projTimes) + + log.Printf("[regen] 完成: 成功 %d, 降级 %d, 共 %d", ok, fail, total) + + return ok, fail + +} + +// firstValidUserMsg 从会话文件提取首条有效用户消息(已过滤噪音/工具回填), + +// 供摘要降级兜底使用。与 extractMessagesForSummary 不同:不限长度、只取第一条。 + +func firstValidUserMsg(path string) string { + + f, err := os.Open(path) + + if err != nil { + + return "" + + } + + defer f.Close() + + scanner := bufio.NewScanner(f) + + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for scanner.Scan() { + + line := scanner.Bytes() + + if !hasType(line) || !matchType(line, "user") { + + continue + + } + + if bytes.Contains(line, []byte(`"tool_use_id"`)) || bytes.Contains(line, []byte(`"isMeta":true`)) { + + continue + + } + + if msg := extractUserMsg(line); msg != "" { + + return truncStr(msg, 80) + + } + + } + + return "" + } func extractMessagesForSummary(path string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + + defer f.Close() + + var rawMsgs []string + + scanner := bufio.NewScanner(f) + + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + // 收集最多 10 条用户消息(比扫描更多,提高命中有效内容的概率) + + for scanner.Scan() { + + if len(rawMsgs) >= 10 { + + break + + } + + line := scanner.Bytes() + + if !hasType(line) || !matchType(line, "user") { + + continue + + } + + if msg := extractUserMsg(line); msg != "" { + + rawMsgs = append(rawMsgs, msg) + + } + + } + + // 过滤掉短小/噪音消息 + + var meaningful []string + + for _, msg := range rawMsgs { + + msg = strings.TrimSpace(msg) + + if isTrivialMsg(msg) || len([]rune(msg)) < 10 { + + continue + + } + + meaningful = append(meaningful, "用户: "+msg) + + } + + if len(meaningful) < 1 { + + return "" + + } + + // 最多取前 5 条有意义消息,防止过度消耗 + + if len(meaningful) > 5 { + + meaningful = meaningful[:5] + + } + + return strings.Join(meaningful, "\n") + +} + +// maxDetailUserMsgs 历史详情展示的用户消息上限,防极端大文件卡顿 + +const maxDetailUserMsgs = 200 + +// extractUserMessages 读取会话的全部用户消息(正序),供历史详情面板展示。 + +// 与 extractMessagesForSummary 不同:不限 5 条、返回原始消息、不做拼接。 + +func extractUserMessages(path string) []string { + + f, err := os.Open(path) + + if err != nil { + + return nil + + } + 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 { + + if len(msgs) >= maxDetailUserMsgs { + break + } + line := scanner.Bytes() - if !hasType(line) { + + if !hasType(line) || !matchType(line, "user") { + continue + } - if matchType(line, "user") { - if msg := extractUserMsg(line); msg != "" { - msgs = append(msgs, "用户: "+msg) - userCount++ - } + + // 排除工具结果回填与 meta 行 + + if bytes.Contains(line, []byte(`"tool_use_id"`)) || bytes.Contains(line, []byte(`"isMeta":true`)) { + + continue + } + + if msg := extractUserMsg(line); msg != "" { + + msgs = append(msgs, msg) + + } + } - return strings.Join(msgs, "\n") + return msgs + } 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 + // 持久化由 Model.flushCache() 延迟批量写入 + } // cacheFlushMsg 延迟写缓存定时器消息 + type cacheFlushMsg struct{} // --- 收藏 --- 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 := atomicWriteFile(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") + } // pendingFork 记录一次待绑定的 fork:启动 fork 后新会话 ID 未知, + // 等下次扫描时按 cwd + 时间窗口匹配出新建会话再建立关系。 + type pendingFork struct { SourceID string - Cwd string - At time.Time + + Cwd string + + At time.Time } 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 + } + if err := atomicWriteFile(forksPath(home), data, 0o644); err != nil { + log.Printf("[u-tabs] forks write fail: %v", err) + } + } // resolvePendingForks 处理 executeFork 留下的待绑定项: + // 在最新扫描结果中,找出 cwd 匹配且 StartTime 晚于记录时刻的新会话, + // 将其作为 fork 目标记录关系到 forks.json。 + // 匹配窗口 10 分钟,避免时钟偏差;同一 cwd 只匹配最早的一条新会话。 + func resolvePendingForks(pending []pendingFork, projects []*ProjectDir) { + if len(pending) == 0 { + return + } + forks := loadForks() + changed := false + for _, p := range pending { + // 已绑过则跳过 + matched := "" + for _, pd := range projects { + if normalizeCwd(pd.Dir) != normalizeCwd(p.Cwd) { + continue + } + for _, s := range pd.Sessions { + if s.StartTime.IsZero() { + continue + } + // 新会话起始时间晚于 fork 时刻(容许 2 秒时钟偏差) + if !s.StartTime.Before(p.At.Add(-2 * time.Second)) { + if s.ID == p.SourceID { + continue + } + if matched == "" || s.StartTime.Before(findSessionStart(projects, matched)) { + matched = s.ID + } + } + } + } + if matched != "" { + forks[matched] = p.SourceID + changed = true + } + } + if changed { + home, err := os.UserHomeDir() + if err == nil { + data, _ := json.Marshal(forks) + atomicWriteFile(forksPath(home), data, 0o644) + } + } + } func normalizeCwd(s string) string { + return strings.ReplaceAll(s, "\\", "/") + } func findSessionStart(projects []*ProjectDir, id string) time.Time { + for _, pd := range projects { + for _, s := range pd.Sessions { + if s.ID == id { + return s.StartTime + } + } + } + return time.Time{} + } // --- AI 摘要与收藏操作 --- func (m *Model) applySummary(sessionID, summary string, completed, pending []string) { + for _, pd := range m.history.Projects { + for _, s := range pd.Sessions { + if s.ID == sessionID { + if summary == "" { + // 标记为已尝试,防止 nextSummaryCmd 无限重试 + // 用 FirstMsg 作为兜底显示,避免显示空白 + s.AISummary = s.FirstMsg + if s.AISummary == "" { + s.AISummary = s.ID[:8] + } + } else { + s.AISummary = summary + s.Completed = completed + s.Pending = pending + } + updateSummaryInCache(m.history.Cache, sessionID, s.AISummary, s.Completed, s.Pending) + return + } + } + } + } const maxSummaryPerSession = 20 // 每次启动最多生成 N 个摘要 func (m *Model) nextSummaryCmd() tea.Cmd { + if m.summaryCount >= maxSummaryPerSession { + return nil + } + for _, pd := range m.history.Projects { + for _, s := range pd.Sessions { + // 仅对从未生成过摘要的会话生成,避免对已有摘要的会话反复重试 + // (fallback 路径已用 FirstMsg/ID 充充 AISummary,标记为已生成) + if s.AISummary == "" && s.FirstMsg != "" && s.FilePath != "" { + m.summaryCount++ + return generateSummaryCmd(s.FilePath, s.ID) + } + } + } + return nil + } func (m *Model) toggleFavorite() { + if m.history.Favorites == nil { + m.history.Favorites = loadFavorites() + } + s := m.currentSession() + if s == nil { + return + } + if m.history.Favorites[s.ID] { + delete(m.history.Favorites, s.ID) + } else { + m.history.Favorites[s.ID] = true + } + saveFavorites(m.history.Favorites) + } diff --git a/internal/key.go b/internal/key.go index 6cb1d04..21f658e 100644 --- a/internal/key.go +++ b/internal/key.go @@ -5,6 +5,11 @@ import tea "charm.land/bubbletea/v2" // --- 按键分发 --- func (m *Model) handleKey(s string) (*Model, tea.Cmd) { + // 编辑标题时优先走输入处理,避免 q/1/2/u 等被全局拦截 + if m.editTitleMode { + return m.handleHistoryKey(s) + } + switch s { case "q", "ctrl+c": return m, tea.Quit @@ -17,6 +22,8 @@ func (m *Model) handleKey(s string) (*Model, tea.Cmd) { // Tab 快捷切换: 1=工作空间 2=历史对话 if s == "1" { + m.editTitleMode = false + m.editTitleBuf = "" if m.IsHistoryTab() { m.activeGroup = 0 m.resetCursor() @@ -117,6 +124,9 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) { if m.forkMode { return m.handleForkInput(s) } + if m.editTitleMode { + return m.handleEditTitleInput(s) + } switch s { case "up", "k": @@ -159,6 +169,13 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) { } case "f": m.toggleFavorite() + case "e", "E": + if m.currentSession() != nil && !m.forkMode { + s := m.currentSession() + m.editTitleMode = true + m.editTitleBuf = s.DisplayTitle() + m.history.FocusPanel = 1 // 确保右栏可见 + } case "c": if m.currentSession() != nil && m.history.FocusPanel == 1 { m.forkMode = true @@ -168,6 +185,32 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) { return m, nil } +func (m *Model) handleEditTitleInput(s string) (*Model, tea.Cmd) { + switch s { + case "enter": + return m.confirmEditTitle() + case "esc": + m.editTitleMode = false + m.editTitleBuf = "" + case "backspace": + r := []rune(m.editTitleBuf) + if len(r) > 0 { + m.editTitleBuf = string(r[:len(r)-1]) + } + default: + // 编辑状态下忽略导航/功能键,避免 up/down/tab 等被当作字符输入 + switch s { + case "up", "down", "left", "right", "tab", "shift+tab": + // 不处理 + default: + if s != "" && s[0] >= 32 { + m.editTitleBuf += s + } + } + } + return m, nil +} + func (m *Model) handleForkInput(s string) (*Model, tea.Cmd) { switch s { case "enter": diff --git a/internal/launch.go b/internal/launch.go index 8ff940c..40e6f95 100644 --- a/internal/launch.go +++ b/internal/launch.go @@ -3,6 +3,7 @@ package internal import ( "fmt" "log" + "os" "os/exec" "path/filepath" "strconv" @@ -114,42 +115,82 @@ func powershellExe() string { return "powershell" } +// wtExe 返回 Windows Terminal 可执行文件路径;未安装/找不到时返回空串。 +// 查找顺序:PATH 中的 wt.exe → 应用执行别名标准路径 +// %LOCALAPPDATA%\Microsoft\WindowsApps\wt.exe(WindowsApps 常不在 PATH)。 +// 找不到的常见原因:未安装 Windows Terminal(Win10 需 Store 安装)、 +// 或 WindowsApps 执行别名被禁用。调用方据此回退到独立窗口启动。 +func wtExe() string { + if p, err := exec.LookPath("wt.exe"); err == nil { + return p + } + if local := os.Getenv("LOCALAPPDATA"); local != "" { + p := filepath.Join(local, "Microsoft", "WindowsApps", "wt.exe") + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + func launchInWtWithTitle(dir, tabTitle, script string) { encoded := encodePSCommand(script) - color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255)) - args := []string{"-w", "0", "-d", dir, "--tabColor", color} - if tabTitle != "" { - args = append(args, "--title", tabTitle) + ps := powershellExe() + + // 优先 Windows Terminal:在已有窗口(-w 0)里开新标签页 + if wt := wtExe(); wt != "" { + color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255)) + args := []string{"-w", "0", "-d", dir, "--tabColor", color} + if tabTitle != "" { + args = append(args, "--title", tabTitle) + } + args = append(args, ps, "-NoExit", "-EncodedCommand", encoded) + if err := exec.Command(wt, args...).Start(); err == nil { + return + } else { + log.Printf("[u-tabs] launch fail (wt): %v, fallback to cmd start", err) + } } - args = append(args, powershellExe(), "-NoExit", "-EncodedCommand", encoded) - cmd := exec.Command("wt.exe", args...) + + // 回退:未安装 Windows Terminal → cmd start 开独立 PowerShell 窗口 + // start 语法:START ["title"] [options] command [args] + // 首个参数被引号包裹时才视为窗口标题,否则视为命令。 + // Go 的 exec.Command 只在参数含空格时自动添加引号, + // 所以给 tabTitle 追加空格(如尚不含空格)来确保被引号包裹。 + titleArg := tabTitle + // 转义标题中的 cmd 特殊字符,防止命令注入: + // 双引号全部移除;& | < > ^ 前加 ^(顺序:先 ^ 再其余) + titleArg = strings.ReplaceAll(titleArg, `"`, "") + titleArg = strings.ReplaceAll(titleArg, "^", "^^") + titleArg = strings.ReplaceAll(titleArg, "&", "^&") + titleArg = strings.ReplaceAll(titleArg, "|", "^|") + titleArg = strings.ReplaceAll(titleArg, "<", "^<") + titleArg = strings.ReplaceAll(titleArg, ">", "^>") + if titleArg != "" && !strings.ContainsAny(titleArg, " \t") { + titleArg += " " // 迫使 Go 自动加引号,窗口标题尾部空格不影响使用 + } + args := []string{"/c", "start", titleArg, ps, "-NoExit", "-EncodedCommand", encoded} + cmd := exec.Command("cmd.exe", args...) + cmd.Dir = dir if err := cmd.Start(); err != nil { - log.Printf("[u-tabs] launch fail: %v", err) + log.Printf("[u-tabs] launch fail (cmd): %v", err) } } // buildResumeAction 构建恢复会话动作 func buildResumeAction(s *Session) SessionAction { - title := s.CustomTitle - if title == "" { - title = s.ID[:8] - } return SessionAction{ Cwd: s.Cwd, - Title: "resume: " + title, + Title: "resume: " + s.DisplayTitle(), SessionID: s.ID, } } // buildForkAction 构建分叉会话动作 func buildForkAction(s *Session, prompt string) SessionAction { - title := s.CustomTitle - if title == "" { - title = s.ID[:8] - } return SessionAction{ Cwd: s.Cwd, - Title: "fork: " + title, + Title: "fork: " + s.DisplayTitle(), SessionID: s.ID, Fork: true, Prompt: prompt, @@ -184,7 +225,7 @@ func (m *Model) resumeSelected() (*Model, tea.Cmd) { } action := buildResumeAction(s) action.Execute(m) - m.launched = "resume: " + s.CustomTitle + m.launched = "resume: " + s.DisplayTitle() return m, nil } @@ -225,11 +266,7 @@ func (m *Model) executeFork() (*Model, tea.Cmd) { At: time.Now(), }) - title := s.CustomTitle - if title == "" { - title = s.ID[:8] - } - m.launched = "fork: " + title + m.launched = "fork: " + s.DisplayTitle() return m, nil } diff --git a/internal/launch_test.go b/internal/launch_test.go new file mode 100644 index 0000000..2fd1e4b --- /dev/null +++ b/internal/launch_test.go @@ -0,0 +1,22 @@ +package internal + +import ( + "strings" + "testing" +) + +// TestWtExeNeverReturnsBareName 锁定 wtExe 的契约:要么返回真实路径,要么返回空串, +// 绝不能返回裸 "wt.exe"。 +// 裸 "wt.exe" 在未安装 Windows Terminal 的机器上会让 exec.Command 启动报 +// `exec: "wt.exe": executable file not found in %PATH%`(v0.2.2 的崩溃根因)。 +// 空串触发 launchInWtWithTitle 的 cmd /c start 独立窗口回退。 +func TestWtExeNeverReturnsBareName(t *testing.T) { + got := wtExe() + if got == "wt.exe" { + t.Fatalf("wtExe() returned bare %q; must return full path or empty string", got) + } + if got != "" && !strings.HasSuffix(got, "wt.exe") { + t.Fatalf("wtExe() returned unexpected path %q", got) + } + t.Logf("wtExe() = %q (empty ⇒ wt absent → cmd fallback)", got) +} diff --git a/internal/style/style.go b/internal/style/style.go index f50fb37..3936bbd 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -19,12 +19,12 @@ var ( Red = lipgloss.Color("#f7768e") // red // ── component styles ── - TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(Accent) + TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(Accent) SubtitleStyle = lipgloss.NewStyle().Foreground(Dim) - TabActiveStyle = lipgloss.NewStyle().Bold(true).Background(BgPanel).Foreground(Bright).Padding(0, 1) + TabActiveStyle = lipgloss.NewStyle().Bold(true).Background(BgPanel).Foreground(Bright).Padding(0, 1) TabInactiveStyle = lipgloss.NewStyle().Foreground(Dim).Padding(0, 1) - TabSep = lipgloss.NewStyle().Foreground(BgPanel) + TabSep = lipgloss.NewStyle().Foreground(BgPanel) SelStyle = lipgloss.NewStyle().Foreground(BgDark).Background(Purple).Bold(true) NormStyle = lipgloss.NewStyle().Foreground(Fg) diff --git a/internal/update.go b/internal/update.go index 3a4cf2d..340363c 100644 --- a/internal/update.go +++ b/internal/update.go @@ -14,7 +14,7 @@ import ( ) // Version 当前版本,发布时更新 -const Version = "0.2.2" +const Version = "0.2.3" // --- 远程 JSON 结构 --- @@ -25,10 +25,10 @@ type platformInfo struct { } type versionInfo struct { - Version string `json:"version"` - ReleaseDate string `json:"release_date"` - Changelog string `json:"changelog"` - Platforms map[string]platformInfo `json:"platforms"` + Version string `json:"version"` + ReleaseDate string `json:"release_date"` + Changelog string `json:"changelog"` + Platforms map[string]platformInfo `json:"platforms"` } // --- bubbletea 消息类型 --- diff --git a/internal/view.go b/internal/view.go index 584a4ee..c996072 100644 --- a/internal/view.go +++ b/internal/view.go @@ -112,6 +112,7 @@ func (m *Model) renderHelp() []string { m.fmtHelp("←→", "panel"), m.fmtHelp("Enter", "resume"), m.fmtHelp("f", "star"), + m.fmtHelp("e", "rename"), m.fmtHelp("n", "new"), m.fmtHelp("c", "fork"), m.fmtHelp("1", "workspace"), diff --git a/internal/view_hist.go b/internal/view_hist.go index b325420..6ca7380 100644 --- a/internal/view_hist.go +++ b/internal/view_hist.go @@ -198,7 +198,18 @@ func (m *Model) detailColumnLines(w, listH int) []string { if m.history.Favorites[s.ID] { favPrefix = "★ " } - lines = append(lines, style.DetailTitle.Render(fitWidth(favPrefix+s.DisplayTitle(), w))) + // 标题行:编辑模式显示输入框,否则显示普通标题 + if m.editTitleMode { + editLine := " ✏ " + m.editTitleBuf + editW := w - 4 + if lipgloss.Width(editLine) > editW { + editLine = truncateByWidth(editLine, editW-2) + "…" + } + lines = append(lines, style.InputStyle.Render(fitWidth(editLine, w))) + lines = append(lines, style.DimFg.Render(fitWidth(" [Enter]save [Esc]cancel", w))) + } else { + lines = append(lines, style.DetailTitle.Render(fitWidth(favPrefix+s.DisplayTitle(), w))) + } lines = append(lines, style.SepFg.Render(fitWidth(sepRunes(w), w))) fav := "-" diff --git a/main.go b/main.go index 1d3ce91..def341a 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,22 @@ import ( ) func main() { + // --fix-titles: 清理缓存中的低质量 AI 摘要(Claude 困惑回复/噪音/过短), + // 下次启动会重新扫描并重新生成。一次性数据维护命令,不进入 TUI。 + if len(os.Args) > 1 && os.Args[1] == "--fix-titles" { + n := internal.CleanupBadTitles() + fmt.Printf("已清理 %d 个低质量摘要,重启 u-tabs 后将自动重新生成\n", n) + return + } + + // --regen-summaries: 立即并发生成所有缺失的 AI 摘要(AISummary 为空的会话)。 + // 通常在 --fix-titles 之后运行,把清理掉的标题重新解析回来。不进入 TUI。 + if len(os.Args) > 1 && os.Args[1] == "--regen-summaries" { + ok, fail := internal.RegenerateSummaries(4) + fmt.Printf("摘要重新生成完成: 成功 %d, 降级 %d\n", ok, fail) + return + } + internal.InitData() m := internal.NewModel() p := tea.NewProgram(m, tea.WithFPS(0))