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 }