新增: 历史会话标题自定义编辑与摘要质量修复

- 标题自定义编辑:HISTORY 详情按 e 编辑标题,UserTitle 持久化到缓存且最高优先级
- AI 摘要质量修复:困惑回复检测词表收紧,消息提取过滤噪音,修复 JSON 包裹困惑回复漏网
- 数据维护命令:--fix-titles 清理低质量摘要,--regen-summaries 并发生成
- 修复代码审查 9 个问题:退格 UTF-8 损坏、中文输入、flush 调度、误杀合法摘要、
  按键全局拦截、UserTitle 未同步窗口标题、wt 启动回落、cmd 注入防护、前缀误隐藏会话
- 启动链 wt.exe 回退与 cmd start 标题转义
- gofmt 规范化,新增 launch_test.go
This commit is contained in:
lxy
2026-07-31 21:22:17 +08:00
parent 043a7d9227
commit 95f349bcd7
10 changed files with 1465 additions and 126 deletions
+51 -18
View File
@@ -3,6 +3,7 @@ package internal
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"time" "time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
@@ -10,20 +11,22 @@ import (
// Model 主模型 // Model 主模型
type Model struct { type Model struct {
activeGroup int activeGroup int
cursor int cursor int
selected map[int]bool selected map[int]bool
inputBuf string inputBuf string
width int width int
height int height int
launched string launched string
history HistoryState history HistoryState
pendingCmd string pendingCmd string
update UpdateState update UpdateState
wsFocus int // workspace 焦点列: 0=tabs 1=list wsFocus int // workspace 焦点列: 0=tabs 1=list
wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口) wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口)
forkMode bool // 分叉输入模式 forkMode bool // 分叉输入模式
forkBuf string // 分叉方向提示输入 forkBuf string // 分叉方向提示输入
editTitleMode bool // 编辑标题模式
editTitleBuf string // 标题编辑缓冲区
// 性能缓存 // 性能缓存
allSessionsCache []*Session // "全部" tab 会话切片缓存 allSessionsCache []*Session // "全部" tab 会话切片缓存
@@ -88,10 +91,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
total += len(pd.Sessions) total += len(pd.Sessions)
} }
m.launched = fmt.Sprintf("refreshed: %d projects, %d sessions", len(msg.Projects), total) m.launched = fmt.Sprintf("refreshed: %d projects, %d sessions", len(msg.Projects), total)
if len(msg.UpdatedIDs) > 0 { // 无论本次是否有文件更新,都尝试补齐缺失的摘要
return m, m.nextSummaryCmd() return m, m.nextSummaryCmd()
}
return m, nil
case SummaryResultMsg: case SummaryResultMsg:
m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending) m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending)
m.cacheDirty = true m.cacheDirty = true
@@ -133,6 +134,38 @@ func (m *Model) GetPendingCmd() string {
return m.pendingCmd 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() { func (m *Model) flushCache() {
if !m.cacheDirty || m.history.Cache == nil { if !m.cacheDirty || m.history.Cache == nil {
return return
+1252 -76
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -5,6 +5,11 @@ import tea "charm.land/bubbletea/v2"
// --- 按键分发 --- // --- 按键分发 ---
func (m *Model) handleKey(s string) (*Model, tea.Cmd) { func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
// 编辑标题时优先走输入处理,避免 q/1/2/u 等被全局拦截
if m.editTitleMode {
return m.handleHistoryKey(s)
}
switch s { switch s {
case "q", "ctrl+c": case "q", "ctrl+c":
return m, tea.Quit return m, tea.Quit
@@ -17,6 +22,8 @@ func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
// Tab 快捷切换: 1=工作空间 2=历史对话 // Tab 快捷切换: 1=工作空间 2=历史对话
if s == "1" { if s == "1" {
m.editTitleMode = false
m.editTitleBuf = ""
if m.IsHistoryTab() { if m.IsHistoryTab() {
m.activeGroup = 0 m.activeGroup = 0
m.resetCursor() m.resetCursor()
@@ -117,6 +124,9 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
if m.forkMode { if m.forkMode {
return m.handleForkInput(s) return m.handleForkInput(s)
} }
if m.editTitleMode {
return m.handleEditTitleInput(s)
}
switch s { switch s {
case "up", "k": case "up", "k":
@@ -159,6 +169,13 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
} }
case "f": case "f":
m.toggleFavorite() 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": case "c":
if m.currentSession() != nil && m.history.FocusPanel == 1 { if m.currentSession() != nil && m.history.FocusPanel == 1 {
m.forkMode = true m.forkMode = true
@@ -168,6 +185,32 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
return m, nil 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) { func (m *Model) handleForkInput(s string) (*Model, tea.Cmd) {
switch s { switch s {
case "enter": case "enter":
+60 -23
View File
@@ -3,6 +3,7 @@ package internal
import ( import (
"fmt" "fmt"
"log" "log"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -114,42 +115,82 @@ func powershellExe() string {
return "powershell" 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) { func launchInWtWithTitle(dir, tabTitle, script string) {
encoded := encodePSCommand(script) encoded := encodePSCommand(script)
color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255)) ps := powershellExe()
args := []string{"-w", "0", "-d", dir, "--tabColor", color}
if tabTitle != "" { // 优先 Windows Terminal:在已有窗口(-w 0)里开新标签页
args = append(args, "--title", tabTitle) 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 { if err := cmd.Start(); err != nil {
log.Printf("[u-tabs] launch fail: %v", err) log.Printf("[u-tabs] launch fail (cmd): %v", err)
} }
} }
// buildResumeAction 构建恢复会话动作 // buildResumeAction 构建恢复会话动作
func buildResumeAction(s *Session) SessionAction { func buildResumeAction(s *Session) SessionAction {
title := s.CustomTitle
if title == "" {
title = s.ID[:8]
}
return SessionAction{ return SessionAction{
Cwd: s.Cwd, Cwd: s.Cwd,
Title: "resume: " + title, Title: "resume: " + s.DisplayTitle(),
SessionID: s.ID, SessionID: s.ID,
} }
} }
// buildForkAction 构建分叉会话动作 // buildForkAction 构建分叉会话动作
func buildForkAction(s *Session, prompt string) SessionAction { func buildForkAction(s *Session, prompt string) SessionAction {
title := s.CustomTitle
if title == "" {
title = s.ID[:8]
}
return SessionAction{ return SessionAction{
Cwd: s.Cwd, Cwd: s.Cwd,
Title: "fork: " + title, Title: "fork: " + s.DisplayTitle(),
SessionID: s.ID, SessionID: s.ID,
Fork: true, Fork: true,
Prompt: prompt, Prompt: prompt,
@@ -184,7 +225,7 @@ func (m *Model) resumeSelected() (*Model, tea.Cmd) {
} }
action := buildResumeAction(s) action := buildResumeAction(s)
action.Execute(m) action.Execute(m)
m.launched = "resume: " + s.CustomTitle m.launched = "resume: " + s.DisplayTitle()
return m, nil return m, nil
} }
@@ -225,11 +266,7 @@ func (m *Model) executeFork() (*Model, tea.Cmd) {
At: time.Now(), At: time.Now(),
}) })
title := s.CustomTitle m.launched = "fork: " + s.DisplayTitle()
if title == "" {
title = s.ID[:8]
}
m.launched = "fork: " + title
return m, nil return m, nil
} }
+22
View File
@@ -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)
}
+3 -3
View File
@@ -19,12 +19,12 @@ var (
Red = lipgloss.Color("#f7768e") // red Red = lipgloss.Color("#f7768e") // red
// ── component styles ── // ── component styles ──
TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(Accent) TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(Accent)
SubtitleStyle = lipgloss.NewStyle().Foreground(Dim) 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) 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) SelStyle = lipgloss.NewStyle().Foreground(BgDark).Background(Purple).Bold(true)
NormStyle = lipgloss.NewStyle().Foreground(Fg) NormStyle = lipgloss.NewStyle().Foreground(Fg)
+5 -5
View File
@@ -14,7 +14,7 @@ import (
) )
// Version 当前版本,发布时更新 // Version 当前版本,发布时更新
const Version = "0.2.2" const Version = "0.2.3"
// --- 远程 JSON 结构 --- // --- 远程 JSON 结构 ---
@@ -25,10 +25,10 @@ type platformInfo struct {
} }
type versionInfo struct { type versionInfo struct {
Version string `json:"version"` Version string `json:"version"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
Changelog string `json:"changelog"` Changelog string `json:"changelog"`
Platforms map[string]platformInfo `json:"platforms"` Platforms map[string]platformInfo `json:"platforms"`
} }
// --- bubbletea 消息类型 --- // --- bubbletea 消息类型 ---
+1
View File
@@ -112,6 +112,7 @@ func (m *Model) renderHelp() []string {
m.fmtHelp("←→", "panel"), m.fmtHelp("←→", "panel"),
m.fmtHelp("Enter", "resume"), m.fmtHelp("Enter", "resume"),
m.fmtHelp("f", "star"), m.fmtHelp("f", "star"),
m.fmtHelp("e", "rename"),
m.fmtHelp("n", "new"), m.fmtHelp("n", "new"),
m.fmtHelp("c", "fork"), m.fmtHelp("c", "fork"),
m.fmtHelp("1", "workspace"), m.fmtHelp("1", "workspace"),
+12 -1
View File
@@ -198,7 +198,18 @@ func (m *Model) detailColumnLines(w, listH int) []string {
if m.history.Favorites[s.ID] { if m.history.Favorites[s.ID] {
favPrefix = "★ " 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))) lines = append(lines, style.SepFg.Render(fitWidth(sepRunes(w), w)))
fav := "-" fav := "-"
+16
View File
@@ -11,6 +11,22 @@ import (
) )
func main() { 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() internal.InitData()
m := internal.NewModel() m := internal.NewModel()
p := tea.NewProgram(m, tea.WithFPS(0)) p := tea.NewProgram(m, tea.WithFPS(0))