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

- 标题自定义编辑: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
+36 -3
View File
@@ -3,6 +3,7 @@ package internal
import (
"fmt"
"os"
"strings"
"time"
tea "charm.land/bubbletea/v2"
@@ -24,6 +25,8 @@ type Model struct {
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
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
+1214 -38
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) {
// 编辑标题时优先走输入处理,避免 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":
+56 -19
View File
@@ -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)
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, powershellExe(), "-NoExit", "-EncodedCommand", encoded)
cmd := exec.Command("wt.exe", args...)
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)
}
}
// 回退:未安装 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
}
+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)
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
)
// Version 当前版本,发布时更新
const Version = "0.2.2"
const Version = "0.2.3"
// --- 远程 JSON 结构 ---
+1
View File
@@ -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"),
+11
View File
@@ -198,7 +198,18 @@ func (m *Model) detailColumnLines(w, listH int) []string {
if m.history.Favorites[s.ID] {
favPrefix = "★ "
}
// 标题行:编辑模式显示输入框,否则显示普通标题
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 := "-"
+16
View File
@@ -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))