Files
u-tabs/internal/launch.go
T
lxy 95f349bcd7 新增: 历史会话标题自定义编辑与摘要质量修复
- 标题自定义编辑:HISTORY 详情按 e 编辑标题,UserTitle 持久化到缓存且最高优先级
- AI 摘要质量修复:困惑回复检测词表收紧,消息提取过滤噪音,修复 JSON 包裹困惑回复漏网
- 数据维护命令:--fix-titles 清理低质量摘要,--regen-summaries 并发生成
- 修复代码审查 9 个问题:退格 UTF-8 损坏、中文输入、flush 调度、误杀合法摘要、
  按键全局拦截、UserTitle 未同步窗口标题、wt 启动回落、cmd 注入防护、前缀误隐藏会话
- 启动链 wt.exe 回退与 cmd start 标题转义
- gofmt 规范化,新增 launch_test.go
2026-07-31 21:22:17 +08:00

311 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}