- 标题自定义编辑:HISTORY 详情按 e 编辑标题,UserTitle 持久化到缓存且最高优先级 - AI 摘要质量修复:困惑回复检测词表收紧,消息提取过滤噪音,修复 JSON 包裹困惑回复漏网 - 数据维护命令:--fix-titles 清理低质量摘要,--regen-summaries 并发生成 - 修复代码审查 9 个问题:退格 UTF-8 损坏、中文输入、flush 调度、误杀合法摘要、 按键全局拦截、UserTitle 未同步窗口标题、wt 启动回落、cmd 注入防护、前缀误隐藏会话 - 启动链 wt.exe 回退与 cmd start 标题转义 - gofmt 规范化,新增 launch_test.go
311 lines
8.6 KiB
Go
311 lines
8.6 KiB
Go
package internal
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "charm.land/bubbletea/v2"
|
||
)
|
||
|
||
// SessionAction 描述一个 Claude Code 会话启动动作
|
||
type SessionAction struct {
|
||
Cwd string // 工作目录
|
||
Title string // 窗口标题
|
||
SessionID string // resume/fork 目标(空=新建)
|
||
Fork bool // 是否分叉
|
||
Prompt string // 初始提示(fork 方向或 workspace prompt)
|
||
Name string // --name 参数(workspace 启动用)
|
||
}
|
||
|
||
// Execute 执行启动动作
|
||
func (a SessionAction) Execute(m *Model) {
|
||
if isWindows {
|
||
cwd := toWinPath(a.Cwd)
|
||
launchInWtWithTitle(cwd, a.Title, a.buildScript())
|
||
} else {
|
||
m.pendingCmd = a.buildScript()
|
||
}
|
||
}
|
||
|
||
// buildScript 根据平台构建启动脚本
|
||
func (a SessionAction) buildScript() string {
|
||
if isWindows {
|
||
return a.pwshScript()
|
||
}
|
||
return a.bashScript()
|
||
}
|
||
|
||
func (a SessionAction) pwshScript() string {
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s"`, a.Title))
|
||
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"=== %s ===\" -ForegroundColor Cyan", a.Title))
|
||
|
||
if a.Prompt != "" && a.Name == "" {
|
||
// workspace prompt 显示(转义双引号,避免破坏 PowerShell 脚本)
|
||
pe := strings.ReplaceAll(a.Prompt, "\"", "`\"\"")
|
||
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"Prompt: %s\" -ForegroundColor Yellow", pe))
|
||
}
|
||
|
||
b.WriteString(fmt.Sprintf("\n\t\tcd \"%s\"", toWinPath(a.Cwd)))
|
||
b.WriteString("\n\t\tclaude")
|
||
|
||
if a.SessionID != "" {
|
||
b.WriteString(fmt.Sprintf(" -r %s", a.SessionID))
|
||
if a.Fork {
|
||
b.WriteString(" --fork-session")
|
||
}
|
||
}
|
||
if a.Prompt != "" && a.SessionID != "" {
|
||
escaped := strings.ReplaceAll(a.Prompt, `"`, "`\"")
|
||
b.WriteString(fmt.Sprintf(` "%s"`, escaped))
|
||
}
|
||
if a.Name != "" {
|
||
b.WriteString(fmt.Sprintf(` --name "%s"`, a.Name))
|
||
}
|
||
b.WriteString(" --permission-mode bypassPermissions")
|
||
return b.String()
|
||
}
|
||
|
||
func (a SessionAction) bashScript() string {
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== %s ==="`, a.Title, a.Title))
|
||
|
||
if a.Prompt != "" && a.Name == "" {
|
||
// bash: 转义双引号与 $ 反斜杠,避免命令注入/语法错误
|
||
pe := strings.ReplaceAll(a.Prompt, "\\", "\\\\")
|
||
pe = strings.ReplaceAll(pe, "\"", "\\\"")
|
||
pe = strings.ReplaceAll(pe, "$", "\\$")
|
||
b.WriteString(fmt.Sprintf(" && echo \"Prompt: %s\"", pe))
|
||
}
|
||
|
||
b.WriteString(fmt.Sprintf(" && cd \"%s\"", a.Cwd))
|
||
b.WriteString(" && claude")
|
||
|
||
if a.SessionID != "" {
|
||
b.WriteString(fmt.Sprintf(" -r %s", a.SessionID))
|
||
if a.Fork {
|
||
b.WriteString(" --fork-session")
|
||
}
|
||
}
|
||
if a.Prompt != "" && a.SessionID != "" {
|
||
escaped := strings.ReplaceAll(a.Prompt, "'", "'\\''")
|
||
b.WriteString(fmt.Sprintf(` '%s'`, escaped))
|
||
}
|
||
if a.Name != "" {
|
||
b.WriteString(fmt.Sprintf(` --name "%s"`, a.Name))
|
||
}
|
||
b.WriteString(" --permission-mode bypassPermissions")
|
||
return b.String()
|
||
}
|
||
|
||
// --- 启动辅助 ---
|
||
|
||
// powershellExe 返回可用的 PowerShell 可执行文件名:优先 pwsh (PS7),找不到则回退 powershell (PS5,Windows 自带)。
|
||
// 脚本只用 $Host.UI.RawUI、Write-Host、cd 等兼容语法,PS5 也能跑。
|
||
func powershellExe() string {
|
||
if _, err := exec.LookPath("pwsh"); err == nil {
|
||
return "pwsh"
|
||
}
|
||
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, 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 (cmd): %v", err)
|
||
}
|
||
}
|
||
|
||
// buildResumeAction 构建恢复会话动作
|
||
func buildResumeAction(s *Session) SessionAction {
|
||
return SessionAction{
|
||
Cwd: s.Cwd,
|
||
Title: "resume: " + s.DisplayTitle(),
|
||
SessionID: s.ID,
|
||
}
|
||
}
|
||
|
||
// buildForkAction 构建分叉会话动作
|
||
func buildForkAction(s *Session, prompt string) SessionAction {
|
||
return SessionAction{
|
||
Cwd: s.Cwd,
|
||
Title: "fork: " + s.DisplayTitle(),
|
||
SessionID: s.ID,
|
||
Fork: true,
|
||
Prompt: prompt,
|
||
}
|
||
}
|
||
|
||
// buildNewAction 构建新建会话动作
|
||
func buildNewAction(cwd string) SessionAction {
|
||
title := filepath.Base(cwd) + " - new"
|
||
return SessionAction{
|
||
Cwd: cwd,
|
||
Title: title,
|
||
}
|
||
}
|
||
|
||
// buildWorkspaceAction 构建工作空间启动动作
|
||
func buildWorkspaceAction(ws Workspace) SessionAction {
|
||
return SessionAction{
|
||
Cwd: ws.Dir,
|
||
Title: ws.Title,
|
||
Prompt: ws.Prompt,
|
||
Name: ws.Title,
|
||
}
|
||
}
|
||
|
||
// --- 会话操作 ---
|
||
|
||
func (m *Model) resumeSelected() (*Model, tea.Cmd) {
|
||
s := m.currentSession()
|
||
if s == nil {
|
||
return m, nil
|
||
}
|
||
action := buildResumeAction(s)
|
||
action.Execute(m)
|
||
m.launched = "resume: " + s.DisplayTitle()
|
||
return m, nil
|
||
}
|
||
|
||
func (m *Model) newSessionFromHistory() (*Model, tea.Cmd) {
|
||
s := m.currentSession()
|
||
cwd := ""
|
||
if s != nil {
|
||
cwd = s.Cwd
|
||
} else {
|
||
cwd = m.currentDirCwd()
|
||
}
|
||
if cwd == "" {
|
||
return m, nil
|
||
}
|
||
action := buildNewAction(cwd)
|
||
action.Execute(m)
|
||
m.launched = "new: " + filepath.Base(cwd)
|
||
return m, nil
|
||
}
|
||
|
||
func (m *Model) executeFork() (*Model, tea.Cmd) {
|
||
s := m.currentSession()
|
||
if s == nil {
|
||
m.forkMode = false
|
||
return m, nil
|
||
}
|
||
m.forkMode = false
|
||
|
||
action := buildForkAction(s, m.forkBuf)
|
||
m.forkBuf = ""
|
||
action.Execute(m)
|
||
|
||
// 记录待绑定 fork:新会话 ID 由 claude CLI 生成,此刻拿不到,
|
||
// 留到下次扫描时按 cwd + 时间窗口匹配
|
||
m.pendingForks = append(m.pendingForks, pendingFork{
|
||
SourceID: s.ID,
|
||
Cwd: s.Cwd,
|
||
At: time.Now(),
|
||
})
|
||
|
||
m.launched = "fork: " + s.DisplayTitle()
|
||
return m, nil
|
||
}
|
||
|
||
func (m *Model) launchSelected() (*Model, tea.Cmd) {
|
||
if len(m.selected) > 0 {
|
||
var launched []string
|
||
for idx := range m.selected {
|
||
ws := &AllWorkspaces[idx]
|
||
action := buildWorkspaceAction(*ws)
|
||
action.Execute(m)
|
||
launched = append(launched, ws.Title)
|
||
}
|
||
m.launched = strings.Join(launched, ", ")
|
||
m.selected = make(map[int]bool)
|
||
return m, nil
|
||
}
|
||
svcs := WorkspacesByGroup(Groups[m.activeGroup].Label)
|
||
if m.cursor < len(svcs) {
|
||
ws := svcs[m.cursor]
|
||
action := buildWorkspaceAction(ws)
|
||
action.Execute(m)
|
||
m.launched = ws.Title
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *Model) launchByInput() (*Model, tea.Cmd) {
|
||
num, err := strconv.Atoi(m.inputBuf)
|
||
if err != nil {
|
||
m.inputBuf = ""
|
||
return m, nil
|
||
}
|
||
ws := FindByNumber(num)
|
||
if ws != nil {
|
||
action := buildWorkspaceAction(*ws)
|
||
action.Execute(m)
|
||
m.launched = ws.Title
|
||
}
|
||
m.inputBuf = ""
|
||
return m, nil
|
||
}
|