From 401713fc5ac8115ac301914341db395c6dab81a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Mon, 18 May 2026 17:28:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E5=B8=83=E5=B1=80=E5=AF=B9=E9=BD=90=E4=B8=8E=E6=97=B6=E5=8C=BA?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app.go | 554 ++++++++++++++++++++++++++++++-------------- internal/history.go | 118 +++++++--- main.go | 16 ++ 3 files changed, 482 insertions(+), 206 deletions(-) diff --git a/internal/app.go b/internal/app.go index 2999618..b286051 100644 --- a/internal/app.go +++ b/internal/app.go @@ -7,7 +7,9 @@ import ( "log" "math/big" "os/exec" + "runtime" "strconv" + "path/filepath" "strings" "unicode/utf16" "unicode/utf8" @@ -27,6 +29,7 @@ type Model struct { height int launched string history HistoryState + pendingCmd string // Linux: 退出后执行的命令 } func NewModel() *Model { @@ -56,9 +59,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.history.Projects = msg.Projects m.history.Loaded = true m.history.Scanning = false - return m, m.nextSummaryCmd() + total := 0 + for _, pd := range msg.Projects { + 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 case SummaryResultMsg: - m.applySummary(msg.SessionID, msg.Summary) + m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending) return m, m.nextSummaryCmd() } return m, nil @@ -168,6 +179,11 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) { } else { return m.resumeSelected() } + case "n": + return m.newSessionFromHistory() + case "r", "f5": + m.history.Scanning = true + return m, ScanSessionsCmd() } return m, nil } @@ -177,7 +193,7 @@ func (m *Model) moveHistoryDir(dir int) { return } m.history.DirCursor += dir - maxDir := len(m.history.Projects) // 含"全部" + maxDir := len(m.history.Projects) + 1 // 含"全部" if m.history.DirCursor < 0 { m.history.DirCursor = maxDir - 1 } @@ -233,15 +249,47 @@ func (m *Model) resumeSelected() (*Model, tea.Cmd) { if s == nil { return m, nil } - go resumeSession(s) + if runtime.GOOS == "windows" { + go resumeSession(s) + } else { + cwd := s.Cwd + title := s.CustomTitle + if title == "" { + title = s.ID[:8] + } + m.pendingCmd = fmt.Sprintf(`printf '\033]0;resume: %s\007' && echo "=== Resuming: %s ===" && cd "%s" && claude -r %s`, title, title, cwd, s.ID) + } m.launched = "resume: " + s.CustomTitle return m, nil } +func (m *Model) newSessionFromHistory() (*Model, tea.Cmd) { + s := m.currentSession() + if s == nil { + return m, nil + } + cwd := s.Cwd + if runtime.GOOS == "windows" { + cwd = strings.ReplaceAll(cwd, "/", "\\") + } + title := filepath.Base(cwd) + " - new" + if runtime.GOOS == "windows" { + script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s" +Write-Host "=== New Session @ %s ===" -ForegroundColor Cyan +cd "%s" +claude --permission-mode bypassPermissions`, title, cwd, cwd) + launchInWtWithTitle(cwd, title, script) + } else { + m.pendingCmd = fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== New Session @ %s ===" && cd "%s" && claude --permission-mode bypassPermissions`, title, cwd, cwd) + } + m.launched = "new: " + filepath.Base(cwd) + return m, nil +} + // --- AI 摘要 --- -func (m *Model) applySummary(sessionID, summary string) { +func (m *Model) applySummary(sessionID, summary string, completed, pending []string) { if summary == "" { return } @@ -249,7 +297,9 @@ func (m *Model) applySummary(sessionID, summary string) { for _, s := range pd.Sessions { if s.ID == sessionID { s.AISummary = summary - saveSummaryToCache(sessionID, summary) + s.Completed = completed + s.Pending = pending + saveSummaryToCache(sessionID, summary, completed, pending) return } } @@ -259,7 +309,9 @@ func (m *Model) applySummary(sessionID, summary string) { func (m *Model) nextSummaryCmd() tea.Cmd { for _, pd := range m.history.Projects { for _, s := range pd.Sessions { - if s.AISummary == "" && s.FilePath != "" && s.FirstMsg != "" { + needSummary := s.AISummary == "" && s.FirstMsg != "" + needDetail := s.AISummary != "" && len(s.Completed) == 0 && len(s.Pending) == 0 + if (needSummary || needDetail) && s.FilePath != "" { return generateSummaryCmd(s.FilePath, s.ID) } } @@ -299,7 +351,7 @@ func (m *Model) launchSelected() (*Model, tea.Cmd) { var launched []string for idx := range m.selected { ws := &AllWorkspaces[idx] - go launchWorkspace(*ws) + m.LaunchWorkspace(*ws) launched = append(launched, ws.Title) } m.launched = strings.Join(launched, ", ") @@ -309,7 +361,7 @@ func (m *Model) launchSelected() (*Model, tea.Cmd) { svcs := WorkspacesByGroup(Groups[m.activeGroup].Label) if m.cursor < len(svcs) { ws := svcs[m.cursor] - go launchWorkspace(ws) + m.LaunchWorkspace(ws) m.launched = ws.Title } return m, nil @@ -323,7 +375,7 @@ func (m *Model) launchByInput() (*Model, tea.Cmd) { } ws := FindByNumber(num) if ws != nil { - go launchWorkspace(*ws) + m.LaunchWorkspace(*ws) m.launched = ws.Title } m.inputBuf = "" @@ -362,7 +414,7 @@ func (m *Model) render() string { if ok { b.WriteString(lipgloss.NewStyle(). Bold(true).Background(style.BgPanel).Foreground(gs.GetForeground()). - Padding(0, 1).Render(label)) + Render(label)) } else { b.WriteString(style.TabActiveStyle.Render(label)) } @@ -378,7 +430,7 @@ func (m *Model) render() string { gs := style.GroupStyles["HISTORY"] b.WriteString(lipgloss.NewStyle(). Bold(true).Background(style.BgPanel).Foreground(gs.GetForeground()). - Padding(0, 1).Render(histLabel)) + Render(histLabel)) } else { b.WriteString(style.TabInactiveStyle.Render(histLabel)) } @@ -420,7 +472,9 @@ func (m *Model) renderHelp() []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("n", "new"), + m.fmtHelp("1-"+fmt.Sprintf("%d", m.totalTabs()), "tab"), + m.fmtHelp("r/F5", "refresh"), m.fmtHelp("q", "quit"), } } @@ -446,10 +500,24 @@ func (m *Model) renderWorkspace() string { return style.SubtitleStyle.Render(" empty") } + // 预留 header(2) + footer(2),面板边框+标题占 3 行 + availH := max(3, m.height-4-3) + start, end := viewport(m.cursor, len(svcs), availH) + // 左栏: 列表 var left strings.Builder innerW := listW - 4 - for i, ws := range svcs { + + // 计算 title 最大宽度用于对齐 + maxTitleW := 0 + for _, ws := range svcs { + if tw := stringWidth(ws.Title); tw > maxTitleW { + maxTitleW = tw + } + } + + for i := start; i < end; i++ { + ws := svcs[i] cur := " " if i == m.cursor { cur = "▸" @@ -459,11 +527,13 @@ func (m *Model) renderWorkspace() string { mark = "✓" } + paddedTitle := padRightByWidth(ws.Title, maxTitleW) + if i == m.cursor { // 选中行:纯文本,让 SelStyle 统一着色 prefix := cur + " " + mark + " " + fmt.Sprintf("%02d", ws.N) + " " remainW := max(10, innerW-len(prefix)) - text := truncateByWidth(ws.Title+" "+ws.Prompt, remainW) + text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW) left.WriteString(style.SelStyle.Width(innerW).Render(prefix + text)) } else { // 非选中行:子样式着色 @@ -474,7 +544,7 @@ func (m *Model) renderWorkspace() string { 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) + text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW) left.WriteString(style.NormStyle.Render(prefix + text)) } left.WriteString("\n") @@ -519,7 +589,11 @@ func (m *Model) renderWorkspace() string { right.WriteString("\n") right.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(" " + strings.Repeat("─", detailW-6))) right.WriteString("\n") - right.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Italic(true).Render(" $ wt → CC @" + ws.Title)) + hintCmd := "wt" + if runtime.GOOS != "windows" { + hintCmd = "bash" + } + right.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Italic(true).Render(" $ "+hintCmd+" → CC @"+ws.Title)) right.WriteString("\n") right.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" [Enter]start [Space]multi")) } else { @@ -539,174 +613,224 @@ func (m *Model) renderWorkspace() string { // --- HISTORY 三栏渲染 --- func (m *Model) renderHistory() string { - if m.history.Scanning { - return style.ScanningStyle.Render(" scanning sessions...") - } - if !m.history.Loaded { + if !m.history.Loaded && !m.history.Scanning { return style.ScanningStyle.Render(" press Tab to load sessions...") } + if len(m.history.Projects) == 0 && m.history.Scanning { + return style.ScanningStyle.Render(" scanning 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) + // 三个独立面板,Width() 含边框,内容宽度 = Width - 2 + avail := m.width - 8 + dirW := max(18, avail*20/100) + sessW := max(28, avail*40/100) + detailW := avail - dirW - sessW + listH := max(3, m.height-7) - // 左栏: 目录列表 - dirBox := m.renderDirPanel(dirW) - // 中栏: 会话列表 - sessBox := m.renderSessPanel(sessW) - // 右栏: 详情 - detailBox := m.renderHistoryDetail(detailW) + dirCw := dirW - 2 + sessCw := sessW - 2 + detailCw := detailW - 2 - return lipgloss.JoinHorizontal(lipgloss.Left, dirBox, " ", sessBox, " ", detailBox) + dirLines := m.dirColumnLines(dirCw, listH-1) + sessLines := m.sessColumnLines(sessCw, listH-1) + detailLines := m.detailColumnLines(detailCw, listH-1) + + // 统一高度:补空行到相同行数 + maxRows := min(listH-1, max(max(len(dirLines), len(sessLines)), len(detailLines))) + padLines := func(lines []string, w int) string { + for len(lines) < maxRows { + lines = append(lines, strings.Repeat(" ", w)) + } + return strings.Join(lines, "\n") + } + + panelSty := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(style.BgPanel) + + dirBox := panelSty.Width(dirW).Render( + style.DetailTitle.Render("dirs") + "\n" + padLines(dirLines, dirCw)) + sessBox := panelSty.Width(sessW).Render( + style.DetailTitle.Render("sessions") + "\n" + padLines(sessLines, sessCw)) + detailBox := panelSty.Width(detailW).Render( + style.DetailTitle.Render("detail") + "\n" + padLines(detailLines, detailCw)) + + return lipgloss.JoinHorizontal(lipgloss.Top, dirBox, " ", sessBox, " ", detailBox) } -func (m *Model) renderDirPanel(w int) string { - var b strings.Builder - innerW := w - 4 +func (m *Model) dirColumnLines(w, listH int) []string { + var lines []string + total := len(m.history.Projects) + 1 + start, end := viewport(m.history.DirCursor, total, listH) - b.WriteString(style.DetailTitle.Render(" dirs ")) - b.WriteString("\n") + prefixW := 3 // " " + cur(1) + " " - // "全部" 行 (index 0) - totalSess := 0 - for _, pd := range m.history.Projects { - totalSess += len(pd.Sessions) + buildDirLine := func(cur, name, cnt string, selected bool) string { + cntW := stringWidth(cnt) + nameW := max(2, w-prefixW-cntW) + name = truncateByWidth(name, nameW) + line := " " + cur + " " + padRightByWidth(name, nameW) + cnt + sty := style.NormStyle + if selected { + sty = style.SelStyle + } + return sty.Render(padRightByWidth(truncateByWidth(line, w), w)) } - cur := " " - if m.history.DirCursor == 0 { - cur = "▸" - } - if m.history.DirCursor == 0 && m.history.FocusPanel == 0 { - line := fmt.Sprintf("%s 全部 (%d)", cur, totalSess) - b.WriteString(style.SelStyle.Width(innerW).Render(line)) - } else { - cnt := style.DirCountStyle.Render(fmt.Sprintf("(%d)", totalSess)) - line := fmt.Sprintf("%s 全部 %s", cur, cnt) - b.WriteString(style.NormStyle.Render(line)) - } - b.WriteString("`n") - for i, pd := range m.history.Projects { + if start == 0 { + totalSess := 0 + for _, pd := range m.history.Projects { + totalSess += len(pd.Sessions) + } + cur := " " + if m.history.DirCursor == 0 { + cur = "▸" + } + lines = append(lines, buildDirLine(cur, "全部", fmt.Sprintf("(%d)", totalSess), + m.history.DirCursor == 0 && m.history.FocusPanel == 0)) + } + + for i := 0; i < len(m.history.Projects); i++ { + if i+1 < start || i+1 >= end { + continue + } + pd := m.history.Projects[i] cur := " " if m.history.DirCursor == i+1 { cur = "▸" } - name := truncateByWidth(pd.DirShort, max(8, innerW-8)) - - if m.history.DirCursor == i+1 && 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") + lines = append(lines, buildDirLine(cur, pd.DirShort, fmt.Sprintf("(%d)", len(pd.Sessions)), + m.history.DirCursor == i+1 && m.history.FocusPanel == 0)) } - - return lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(style.BgPanel). - Width(w). - Padding(0, 1). - Render(b.String()) + return lines } -func (m *Model) renderSessPanel(w int) string { - var b strings.Builder - innerW := w - 4 - - b.WriteString(style.DetailTitle.Render(" sessions ")) - b.WriteString("\n") - +func (m *Model) sessColumnLines(w, listH int) []string { 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 []string{style.SubtitleStyle.Render(fitWidth(" no sessions", w))} } - return lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(style.BgPanel). - Width(w). - Padding(0, 1). - Render(b.String()) + prefixW := 15 // " " + cur(2) + timeStr(11) + " " + textW := max(8, w-prefixW) + + // 预渲染每个 session 为 1~N 行 + type sessBlock struct { + lines []string + } + var blocks []sessBlock + totalLines := 0 + curLineOffset := 0 + + for i, s := range sessions { + cur := " " + if i == m.history.SessCursor { + cur = "▸ " + } + timeStr := s.StartTime.Local().Format("01-02 15:04") + title := s.DisplayTitle() + + wrapped := wrapByWidth(title, textW) + if len(wrapped) == 0 { + wrapped = []string{""} + } + + sty := style.NormStyle + if i == m.history.SessCursor && m.history.FocusPanel == 1 { + sty = style.SelStyle + } + + var blockLines []string + for li, part := range wrapped { + var line string + if li == 0 { + line = fmt.Sprintf(" %s%s %s", cur, timeStr, part) + } else { + line = strings.Repeat(" ", prefixW) + part + } + blockLines = append(blockLines, sty.Render(padRightByWidth(truncateByWidth(line, w), w))) + } + + if i == m.history.SessCursor { + curLineOffset = totalLines + } + totalLines += len(blockLines) + blocks = append(blocks, sessBlock{lines: blockLines}) + } + + // 按总行数做 viewport + startLine, endLine := viewport(curLineOffset, totalLines, listH) + + var allLines []string + for _, b := range blocks { + allLines = append(allLines, b.lines...) + } + if endLine > len(allLines) { + endLine = len(allLines) + } + return allLines[startLine:endLine] } -func (m *Model) renderHistoryDetail(w int) string { - var b strings.Builder - +func (m *Model) detailColumnLines(w, listH int) []string { 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 []string{lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" ← select to view", w))} } - return lipgloss.NewStyle(). - Border(lipgloss.NormalBorder()). - BorderForeground(style.BgPanel). - Width(w). - Padding(0, 1). - Render(b.String()) + sepSty := lipgloss.NewStyle().Foreground(style.BgPanel) + var lines []string + + // Title + lines = append(lines, style.DetailTitle.Render(fitWidth(" "+s.DisplayTitle(), w))) + + // Separator + lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) + + // Key-value rows + kvRows := []struct{ k, v string }{ + {"dir", s.Cwd}, + {"time", fmt.Sprintf("%s ~ %s", s.StartTime.Local().Format("01-02 15:04"), s.EndTime.Local().Format("15:04"))}, + {"msgs", fmt.Sprintf("%d", s.MsgCount)}, + } + for _, r := range kvRows { + line := fmt.Sprintf(" %-4s %s", r.k, r.v) + lines = append(lines, style.NormStyle.Render(fitWidth(line, w))) + } + + // Summary + if summary := s.DisplaySummary(); summary != "" { + lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) + lines = append(lines, style.SessionSummaryStyle.Render(fitWidth(" "+summary, w))) + } + + // Completed items + if len(s.Completed) > 0 { + lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) + lines = append(lines, lipgloss.NewStyle().Foreground(style.Success).Bold(true).Render(fitWidth(" ✓ 已完成", w))) + for _, item := range s.Completed { + lines = append(lines, style.NormStyle.Render(fitWidth(" · "+cleanText(item), w))) + } + } + + // Pending items + if len(s.Pending) > 0 { + lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) + lines = append(lines, lipgloss.NewStyle().Foreground(style.Warning).Bold(true).Render(fitWidth(" ○ 待办", w))) + for _, item := range s.Pending { + lines = append(lines, style.NormStyle.Render(fitWidth(" · "+cleanText(item), w))) + } + } + + // Hint + lines = append(lines, lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" [Enter]resume", w))) + + if len(lines) > listH { + lines = lines[:listH] + } + return lines } // --- 工具 --- @@ -716,12 +840,69 @@ func (m *Model) fmtHelp(key, desc string) string { lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc) } +// viewport 返回 [start, end) 区间,保证 cursor 在可见范围内 +func viewport(cursor, total, height int) (int, int) { + if total <= height { + return 0, total + } + half := height / 2 + start := cursor - half + if start < 0 { + start = 0 + } + end := start + height + if end > total { + end = total + start = end - height + if start < 0 { + start = 0 + } + } + return start, end +} + +// stringWidth 返回字符串的终端显示宽度 +func stringWidth(s string) int { + w := 0 + for _, r := range s { + w += runeWidth(r) + } + return w +} + +// padRightByWidth 补空格到指定视觉宽度 +func padRightByWidth(s string, targetW int) string { + w := stringWidth(s) + if w >= targetW { + return s + } + return s + strings.Repeat(" ", targetW-w) +} + +// wrapByWidth 按显示宽度换行,返回多行文本 +func wrapByWidth(s string, maxW int) []string { + if maxW <= 0 || s == "" { + return []string{s} + } + var lines []string + for s != "" { + cut := truncateByWidth(s, maxW) + lines = append(lines, cut) + s = s[len(cut):] + } + return lines +} + +// fitWidth 截断并填充到精确视觉宽度 +func fitWidth(s string, w int) string { + return padRightByWidth(truncateByWidth(s, w), w) +} + // truncateByWidth 按显示宽度截断字符串,不切断多字节字符 func truncateByWidth(s string, maxW int) string { w := 0 for i := 0; i < len(s); { - _, size := utf8.DecodeRuneInString(s[i:]) - r := rune(s[i]) + r, size := utf8.DecodeRuneInString(s[i:]) rw := runeWidth(r) if w+rw > maxW { return s[:i] @@ -750,45 +931,67 @@ func runeWidth(r rune) int { return 1 } -// --- 启动 --- - -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", dir, - "--tabColor", color, - "pwsh", "-NoExit", "-EncodedCommand", encoded, - ) - if err := cmd.Start(); err != nil { - log.Printf("[u-tabs] launch fail: %v", err) +func launchInWtWithTitle(dir, tabTitle, script string) { + if runtime.GOOS == "windows" { + 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) + } + args = append(args, "pwsh", "-NoExit", "-EncodedCommand", encoded) + cmd := exec.Command("wt.exe", args...) + if err := cmd.Start(); err != nil { + log.Printf("[u-tabs] launch fail: %v", err) + } } } -func launchWorkspace(ws Workspace) { +func (m *Model) LaunchWorkspace(ws Workspace) { script := buildLaunchScript(ws) - launchInWt(ws.Dir, script) + if runtime.GOOS != "windows" { + m.pendingCmd = "cd '" + ws.Dir + "' && " + script + return + } + launchInWtWithTitle(ws.Dir, "", script) +} + +// GetPendingCmd 返回退出后需执行的命令(Linux 用) +func (m *Model) GetPendingCmd() string { + return m.pendingCmd } func resumeSession(s *Session) { - cwd := strings.ReplaceAll(s.Cwd, "/", "\\") + cwd := s.Cwd + if runtime.GOOS == "windows" { + cwd = strings.ReplaceAll(s.Cwd, "/", "\\") + } title := s.CustomTitle if title == "" { title = s.ID[:8] } - script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "resume: %s" + var script string + if runtime.GOOS == "windows" { + 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) +claude -r %s --permission-mode bypassPermissions`, title, title, cwd, s.ID) + } else { + script = fmt.Sprintf(`printf '\033]0;resume: %s\007' && echo "=== Resuming: %s ===" && cd "%s" && claude -r %s`, title, title, cwd, s.ID) + } + launchInWtWithTitle(cwd, "", script) } func buildLaunchScript(ws Workspace) string { - return fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s" + if runtime.GOOS == "windows" { + return fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s" Write-Host "=== %s ===" -ForegroundColor Cyan Write-Host "Prompt: %s" -ForegroundColor Yellow cd "%s" claude --name "%s" --permission-mode bypassPermissions`, + ws.Title, ws.Title, ws.Prompt, ws.Dir, ws.Title) + } + return fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== %s ===" && echo "Prompt: %s" && cd "%s" && claude --name "%s"`, ws.Title, ws.Title, ws.Prompt, ws.Dir, ws.Title) } @@ -806,3 +1009,4 @@ func randRange(min, max int) int { n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min))) return min + int(n.Int64()) } + diff --git a/internal/history.go b/internal/history.go index 590cc7a..478793a 100644 --- a/internal/history.go +++ b/internal/history.go @@ -27,6 +27,8 @@ type Session struct { FirstMsg string // 首条用户消息 (截断) AwaySummary string // away_summary 系统摘要 AISummary string // AI 生成的摘要 (缓存) + Completed []string // AI 提取的已完成项 + Pending []string // AI 提取的待办项 FilePath string // JSONL 文件完整路径 } @@ -39,6 +41,10 @@ type ProjectDir struct { // DisplayTitle 返回会话显示标题 func (s *Session) DisplayTitle() string { + return cleanText(s.displayTitleRaw()) +} + +func (s *Session) displayTitleRaw() string { if s.AISummary != "" { return s.AISummary } @@ -53,12 +59,27 @@ func (s *Session) DisplayTitle() string { // 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 @@ -77,28 +98,31 @@ func (m *Model) IsHistoryTab() bool { // --- 扫描 --- type ScanCompleteMsg struct { - Projects []*ProjectDir + Projects []*ProjectDir + UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要 } func ScanSessionsCmd() tea.Cmd { return func() tea.Msg { - return ScanCompleteMsg{Projects: scanAllProjects()} + projects, updated := scanAllProjects() + return ScanCompleteMsg{Projects: projects, UpdatedIDs: updated} } } -func scanAllProjects() []*ProjectDir { +func scanAllProjects() ([]*ProjectDir, map[string]bool) { home, err := os.UserHomeDir() if err != nil { - return nil + return nil, nil } projectsDir := filepath.Join(home, ".claude", "projects") entries, err := os.ReadDir(projectsDir) if err != nil { - return nil + return nil, nil } cache := loadCache(home) dirMap := make(map[string]*ProjectDir) + updatedIDs := make(map[string]bool) for _, entry := range entries { if !entry.IsDir() { @@ -129,6 +153,11 @@ func scanAllProjects() []*ProjectDir { 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()) } @@ -146,7 +175,7 @@ func scanAllProjects() []*ProjectDir { sort.Slice(result, func(i, j int) bool { return result[i].Dir < result[j].Dir }) - return result + return result, updatedIDs } func addSessionToDir(dirMap map[string]*ProjectDir, s *Session) { @@ -174,18 +203,22 @@ func scanSessionFile(path string, sessionID string) *Session { 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() + // 前 20 行内出现 queue-operation → claude -p 会话,跳过 + if lineCount <= 20 && matchType(line, "queue-operation") { + return nil + } + if !hasType(line) { continue } - scannedLines++ // cwd — 统一为正斜杠 if s.Cwd == "" { @@ -230,13 +263,6 @@ func scanSessionFile(path string, sessionID string) *Session { break } } - - // 按 scannedLines/lineCount 比例估算总消息数 - if lineCount > scanLineLimit && scannedLines > 0 { - ratio := float64(lineCount) / float64(scanLineLimit) - s.MsgCount = int(float64(s.MsgCount) * ratio) - } - return s } @@ -287,7 +313,7 @@ func extractUserMsg(line []byte) string { region := line[searchStart:] if tidx := bytes.Index(region, []byte(`"text":"`)); tidx >= 0 { var text string - if extractJSONString(region[tidx+6:], &text) && text != "" && + if extractJSONString(region[tidx+7:], &text) && text != "" && !strings.HasPrefix(text, "Base directory") { return text } @@ -331,7 +357,7 @@ func findStringEnd(data []byte) int { // --- 缓存 --- -const cacheVersion = 1 +const cacheVersion = 2 type cacheFile struct { Version int `json:"version"` @@ -348,6 +374,8 @@ type cacheEntry struct { FirstMsg string `json:"firstMsg"` AwaySummary string `json:"awaySummary"` AISummary string `json:"aiSummary"` + Completed []string `json:"completed"` + Pending []string `json:"pending"` FilePath string `json:"filePath"` } @@ -357,6 +385,7 @@ func (e *cacheEntry) ToSession(id string) *Session { 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, } } @@ -367,6 +396,7 @@ func cacheEntryFrom(s *Session, modTime time.Time) *cacheEntry { 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, } } @@ -416,39 +446,63 @@ func truncStr(s string, maxRunes int) string { type SummaryResultMsg struct { SessionID string Summary string + Completed []string + Pending []string } func generateSummaryCmd(filePath, sessionID string) tea.Cmd { return func() tea.Msg { - summary := generateAISummary(filePath) - return SummaryResultMsg{SessionID: sessionID, Summary: summary} + sum, done, todo := generateAISummary(filePath) + return SummaryResultMsg{SessionID: sessionID, Summary: sum, Completed: done, Pending: todo} } } -func generateAISummary(filePath string) string { +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 "" + return "", nil, nil } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - prompt := "请用20字以内中文概括以下对话的主题,只输出概括文字,不加引号、标点或markdown格式:\n\n" + messages + 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 "" + return "", nil, nil } - summary := strings.TrimSpace(string(output)) - summary = strings.Trim(summary, "\"'`*") - summary = strings.TrimPrefix(summary, "概括:") - summary = strings.TrimPrefix(summary, "主题:") - if len(summary) > 60 { - summary = truncStr(summary, 60) + 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 } - return summary + + if len(result.Summary) > 60 { + result.Summary = truncStr(result.Summary, 60) + } + return result.Summary, result.Completed, result.Pending } func extractMessagesForSummary(path string) string { @@ -482,7 +536,7 @@ func extractMessagesForSummary(path string) string { return strings.Join(msgs, "\n") } -func saveSummaryToCache(sessionID, summary string) { +func saveSummaryToCache(sessionID, summary string, completed, pending []string) { home, err := os.UserHomeDir() if err != nil { return @@ -493,5 +547,7 @@ func saveSummaryToCache(sessionID, summary string) { return } entry.AISummary = summary + entry.Completed = completed + entry.Pending = pending saveCache(home, cache) } diff --git a/main.go b/main.go index e1762b3..ff74289 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,8 @@ package main import ( "fmt" "os" + "os/exec" + "runtime" "charm.land/bubbletea/v2" "u-tabs/internal" @@ -16,4 +18,18 @@ func main() { fmt.Fprintf(os.Stderr, "启动失败: %v\n", err) os.Exit(1) } + + // 非 Windows: 退出后执行待运行的命令 + if runtime.GOOS == "windows" { + return + } + cmd := m.GetPendingCmd() + if cmd == "" { + return + } + sh := exec.Command("bash", "-c", cmd) + sh.Stdin = os.Stdin + sh.Stdout = os.Stdout + sh.Stderr = os.Stderr + sh.Run() }