package internal import ( "crypto/rand" "encoding/base64" "fmt" "log" "math/big" "os/exec" "runtime" "strconv" "path/filepath" "strings" "unicode/utf16" "unicode/utf8" "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "u-tabs/internal/style" ) // Model 主模型 type Model struct { activeGroup int cursor int selected map[int]bool inputBuf string width int height int launched string history HistoryState pendingCmd string // Linux: 退出后执行的命令 } func NewModel() *Model { return &Model{ activeGroup: 0, selected: make(map[int]bool), } } func (m *Model) Init() tea.Cmd { return nil } // totalTabs 包含 HISTORY 的总 Tab 数 func (m *Model) totalTabs() int { return len(Groups) + 1 } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: return m.handleKey(msg.String()) case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height case ScanCompleteMsg: m.history.Projects = msg.Projects m.history.Loaded = true m.history.Scanning = false total := 0 for _, pd := range msg.Projects { 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) return m, m.nextSummaryCmd() } return m, nil } // --- 按键分发 --- func (m *Model) handleKey(s string) (*Model, tea.Cmd) { // 全局按键 switch s { case "q", "ctrl+c": return m, tea.Quit } // 数字键跳转 Tab if len(s) == 1 && s[0] >= '1' && s[0] <= '9' { idx, _ := strconv.Atoi(s) if idx <= m.totalTabs() { m.activeGroup = idx - 1 m.cursor = 0 m.inputBuf = "" return m, m.onTabSwitch() } } // HISTORY Tab: tab/left/right 用于面板切换 if m.IsHistoryTab() { return m.handleHistoryKey(s) } // Workspace Tab 按键 switch s { case "tab", "right", "l": m.activeGroup = (m.activeGroup + 1) % m.totalTabs() m.cursor = 0 m.inputBuf = "" return m, m.onTabSwitch() case "shift+tab", "left", "h": m.activeGroup = (m.activeGroup - 1 + m.totalTabs()) % m.totalTabs() m.cursor = 0 m.inputBuf = "" return m, m.onTabSwitch() } return m.handleWorkspaceKey(s) } func (m *Model) onTabSwitch() tea.Cmd { if m.IsHistoryTab() && !m.history.Loaded && !m.history.Scanning { m.history.Scanning = true return ScanSessionsCmd() } return nil } // --- Workspace Tab 按键 --- func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) { switch s { case "up", "k": m.moveCursor(-1) case "down", "j": m.moveCursor(1) case " ": m.toggleMultiSelect() case "enter": if m.inputBuf != "" { return m.launchByInput() } return m.launchSelected() default: if len(s) == 1 && s[0] >= '0' && s[0] <= '9' { m.inputBuf += s } } return m, nil } // --- HISTORY Tab 按键 --- func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) { switch s { case "up", "k": if m.history.FocusPanel == 0 { m.moveHistoryDir(-1) } else { m.moveHistorySess(-1) } case "down", "j": if m.history.FocusPanel == 0 { m.moveHistoryDir(1) } else { m.moveHistorySess(1) } case "tab", "right", "l": if m.history.FocusPanel < 1 { m.history.FocusPanel = 1 } case "shift+tab", "left", "h": if m.history.FocusPanel > 0 { m.history.FocusPanel = 0 } case "enter": if m.history.FocusPanel == 0 { // 在左栏按 enter 切到中栏 m.history.FocusPanel = 1 } else { return m.resumeSelected() } case "n": return m.newSessionFromHistory() case "r", "f5": m.history.Scanning = true return m, ScanSessionsCmd() } return m, nil } func (m *Model) moveHistoryDir(dir int) { if len(m.history.Projects) == 0 { return } m.history.DirCursor += dir maxDir := len(m.history.Projects) + 1 // 含"全部" if m.history.DirCursor < 0 { m.history.DirCursor = maxDir - 1 } if m.history.DirCursor >= maxDir { m.history.DirCursor = 0 } m.history.SessCursor = 0 } func (m *Model) moveHistorySess(dir int) { sessions := m.currentSessions() if len(sessions) == 0 { return } m.history.SessCursor += dir if m.history.SessCursor < 0 { m.history.SessCursor = len(sessions) - 1 } if m.history.SessCursor >= len(sessions) { m.history.SessCursor = 0 } } func (m *Model) currentSessions() []*Session { if len(m.history.Projects) == 0 { return nil } if m.history.DirCursor == 0 { // "全部" — 合并所有目录的会话 var all []*Session for _, pd := range m.history.Projects { all = append(all, pd.Sessions...) } return all } idx := m.history.DirCursor - 1 if idx < len(m.history.Projects) { return m.history.Projects[idx].Sessions } return nil } func (m *Model) currentSession() *Session { sessions := m.currentSessions() if len(sessions) == 0 || m.history.SessCursor >= len(sessions) { return nil } return sessions[m.history.SessCursor] } func (m *Model) resumeSelected() (*Model, tea.Cmd) { s := m.currentSession() if s == nil { return m, nil } if runtime.GOOS == "windows" { go resumeSession(s) } else { cwd := s.Cwd title := s.CustomTitle if title == "" { title = s.ID[:8] } m.pendingCmd = fmt.Sprintf(`printf '\033]0;resume: %s\007' && echo "=== Resuming: %s ===" && cd "%s" && claude -r %s`, title, title, cwd, s.ID) } m.launched = "resume: " + s.CustomTitle return m, nil } func (m *Model) newSessionFromHistory() (*Model, tea.Cmd) { s := m.currentSession() if s == nil { return m, nil } cwd := s.Cwd if runtime.GOOS == "windows" { cwd = strings.ReplaceAll(cwd, "/", "\\") } title := filepath.Base(cwd) + " - new" if runtime.GOOS == "windows" { script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s" Write-Host "=== New Session @ %s ===" -ForegroundColor Cyan cd "%s" claude --permission-mode bypassPermissions`, title, cwd, cwd) launchInWtWithTitle(cwd, title, script) } else { m.pendingCmd = fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== New Session @ %s ===" && cd "%s" && claude --permission-mode bypassPermissions`, title, cwd, cwd) } m.launched = "new: " + filepath.Base(cwd) return m, nil } // --- AI 摘要 --- func (m *Model) applySummary(sessionID, summary string, completed, pending []string) { if summary == "" { return } for _, pd := range m.history.Projects { for _, s := range pd.Sessions { if s.ID == sessionID { s.AISummary = summary s.Completed = completed s.Pending = pending saveSummaryToCache(sessionID, summary, completed, pending) return } } } } func (m *Model) nextSummaryCmd() tea.Cmd { for _, pd := range m.history.Projects { for _, s := range pd.Sessions { needSummary := s.AISummary == "" && s.FirstMsg != "" needDetail := s.AISummary != "" && len(s.Completed) == 0 && len(s.Pending) == 0 if (needSummary || needDetail) && s.FilePath != "" { return generateSummaryCmd(s.FilePath, s.ID) } } } return nil } // --- Workspace 光标 --- func (m *Model) moveCursor(dir int) { svcs := WorkspacesByGroup(Groups[m.activeGroup].Label) if len(svcs) == 0 { return } m.cursor += dir if m.cursor < 0 { m.cursor = len(svcs) - 1 } if m.cursor >= len(svcs) { m.cursor = 0 } } func (m *Model) toggleMultiSelect() { svcs := WorkspacesByGroup(Groups[m.activeGroup].Label) if m.cursor < len(svcs) { idx := svcs[m.cursor].Index if m.selected[idx] { delete(m.selected, idx) } else { m.selected[idx] = true } } } func (m *Model) launchSelected() (*Model, tea.Cmd) { if len(m.selected) > 0 { var launched []string for idx := range m.selected { ws := &AllWorkspaces[idx] m.LaunchWorkspace(*ws) 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] m.LaunchWorkspace(ws) 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 { m.LaunchWorkspace(*ws) m.launched = ws.Title } m.inputBuf = "" return m, nil } // --- View --- func (m *Model) View() tea.View { v := tea.NewView(m.render()) v.AltScreen = true v.MouseMode = tea.MouseModeCellMotion v.WindowTitle = "u-tabs" return v } func (m *Model) render() string { if m.width == 0 { return "loading..." } var b strings.Builder // ── header: title + tabs ── b.WriteString(style.TitleStyle.Render(" u-tabs ")) sep := style.TabSep.Render(" | ") // Workspace Tabs for i, g := range Groups { if i > 0 { b.WriteString(sep) } label := fmt.Sprintf(" %s %s ", g.Label, g.Desc) if i == m.activeGroup { gs, ok := style.GroupStyles[g.Label] if ok { b.WriteString(lipgloss.NewStyle(). Bold(true).Background(style.BgPanel).Foreground(gs.GetForeground()). Render(label)) } else { b.WriteString(style.TabActiveStyle.Render(label)) } } else { b.WriteString(style.TabInactiveStyle.Render(label)) } } // HISTORY Tab b.WriteString(sep) histLabel := " HISTORY 历史 " if m.IsHistoryTab() { gs := style.GroupStyles["HISTORY"] b.WriteString(lipgloss.NewStyle(). Bold(true).Background(style.BgPanel).Foreground(gs.GetForeground()). Render(histLabel)) } else { b.WriteString(style.TabInactiveStyle.Render(histLabel)) } b.WriteString("\n") b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", m.width))) b.WriteString("\n") // ── 内容区 ── if m.IsHistoryTab() { b.WriteString(m.renderHistory()) } else { b.WriteString(m.renderWorkspace()) } // ── footer ── b.WriteString("\n") if m.inputBuf != "" { b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ▶ num:%s [Enter]go [Esc]cancel", m.inputBuf))) } if m.launched != "" { b.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Render(fmt.Sprintf(" ✓ %s", m.launched))) m.launched = "" } b.WriteString("\n") helpParts := m.renderHelp() b.WriteString(" " + strings.Join(helpParts, " ")) if hint := ConfigHint(); hint != "" { b.WriteString("\n" + hint) } return b.String() } func (m *Model) renderHelp() []string { if m.IsHistoryTab() { return []string{ m.fmtHelp("j/k", "sel"), m.fmtHelp("Tab", "→panel"), m.fmtHelp("Enter", "resume"), m.fmtHelp("n", "new"), m.fmtHelp("1-"+fmt.Sprintf("%d", m.totalTabs()), "tab"), m.fmtHelp("r/F5", "refresh"), m.fmtHelp("q", "quit"), } } return []string{ m.fmtHelp("j/k", "sel"), m.fmtHelp("Enter", "run"), m.fmtHelp("Space", "multi"), m.fmtHelp("Tab", "group"), m.fmtHelp("q", "quit"), } } // --- Workspace 渲染 --- func (m *Model) renderWorkspace() string { listW := max(42, min(65, m.width*55/100)) detailW := max(30, m.width-listW-3) g := Groups[m.activeGroup] gs, _ := style.GroupStyles[g.Label] svcs := WorkspacesByGroup(g.Label) if len(svcs) == 0 { return style.SubtitleStyle.Render(" empty") } // 预留 header(2) + footer(2),面板边框+标题占 3 行 availH := max(3, m.height-4-3) start, end := viewport(m.cursor, len(svcs), availH) // 左栏: 列表 var left strings.Builder innerW := listW - 4 // 计算 title 最大宽度用于对齐 maxTitleW := 0 for _, ws := range svcs { if tw := stringWidth(ws.Title); tw > maxTitleW { maxTitleW = tw } } for i := start; i < end; i++ { ws := svcs[i] cur := " " if i == m.cursor { cur = "▸" } mark := " " if m.selected[ws.Index] { mark = "✓" } paddedTitle := padRightByWidth(ws.Title, maxTitleW) if i == m.cursor { // 选中行:纯文本,让 SelStyle 统一着色 prefix := cur + " " + mark + " " + fmt.Sprintf("%02d", ws.N) + " " remainW := max(10, innerW-len(prefix)) text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW) left.WriteString(style.SelStyle.Width(innerW).Render(prefix + text)) } else { // 非选中行:子样式着色 markStr := " " if m.selected[ws.Index] { markStr = style.MarkStyle.Render("✓") } num := style.NumStyle.Render(fmt.Sprintf("%02d", ws.N)) prefix := cur + " " + markStr + " " + num + " " remainW := max(10, innerW-lipgloss.Width(prefix)) text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW) left.WriteString(style.NormStyle.Render(prefix + text)) } left.WriteString("\n") } groupHeader := gs.Render(fmt.Sprintf(" %s · %d ", g.Label, len(svcs))) listBox := lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). BorderForeground(style.BgPanel). Width(listW). Padding(0, 1). Render(groupHeader + "\n" + left.String()) // 右栏: 详情 var right strings.Builder if m.cursor < len(svcs) { ws := svcs[m.cursor] right.WriteString(style.DetailTitle.Render(" detail ")) right.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", detailW-10))) right.WriteString("\n\n") rows := []struct { key string val string sty lipgloss.Style }{ {"dir", ws.Dir, style.ValStyle}, {"no", fmt.Sprintf("%02d · %s", ws.N, ws.Group), style.NumStyle}, {"desc", ws.Prompt, style.ValStyle}, {"tech", ws.Tech, style.TechStyle}, {"deploy", ws.Deploy, style.DeployStyle}, } for _, r := range rows { right.WriteString(" ") right.WriteString(style.KeyStyle.Render(r.key)) right.WriteString(" ") right.WriteString(r.sty.Render(r.val)) right.WriteString("\n") } right.WriteString("\n") right.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(" " + strings.Repeat("─", detailW-6))) right.WriteString("\n") hintCmd := "wt" if runtime.GOOS != "windows" { hintCmd = "bash" } right.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Italic(true).Render(" $ "+hintCmd+" → CC @"+ws.Title)) right.WriteString("\n") right.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" [Enter]start [Space]multi")) } else { right.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" ← select to view")) } detailBox := lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). BorderForeground(style.BgPanel). Width(detailW). Padding(0, 1). Render(right.String()) return lipgloss.JoinHorizontal(lipgloss.Left, listBox, " ", detailBox) } // --- HISTORY 三栏渲染 --- func (m *Model) renderHistory() string { if !m.history.Loaded && !m.history.Scanning { return style.ScanningStyle.Render(" press Tab to load sessions...") } if len(m.history.Projects) == 0 && m.history.Scanning { return style.ScanningStyle.Render(" scanning sessions...") } if len(m.history.Projects) == 0 { return style.SubtitleStyle.Render(" no sessions found") } // 三个独立面板,Width() 含边框,内容宽度 = Width - 2 avail := m.width - 8 dirW := max(18, avail*20/100) sessW := max(28, avail*40/100) detailW := avail - dirW - sessW listH := max(3, m.height-7) dirCw := dirW - 2 sessCw := sessW - 2 detailCw := detailW - 2 dirLines := m.dirColumnLines(dirCw, listH-1) sessLines := m.sessColumnLines(sessCw, listH-1) detailLines := m.detailColumnLines(detailCw, listH-1) // 统一高度:补空行到相同行数 maxRows := min(listH-1, max(max(len(dirLines), len(sessLines)), len(detailLines))) padLines := func(lines []string, w int) string { for len(lines) < maxRows { lines = append(lines, strings.Repeat(" ", w)) } return strings.Join(lines, "\n") } panelSty := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(style.BgPanel) dirBox := panelSty.Width(dirW).Render( style.DetailTitle.Render("dirs") + "\n" + padLines(dirLines, dirCw)) sessBox := panelSty.Width(sessW).Render( style.DetailTitle.Render("sessions") + "\n" + padLines(sessLines, sessCw)) detailBox := panelSty.Width(detailW).Render( style.DetailTitle.Render("detail") + "\n" + padLines(detailLines, detailCw)) return lipgloss.JoinHorizontal(lipgloss.Top, dirBox, " ", sessBox, " ", detailBox) } func (m *Model) dirColumnLines(w, listH int) []string { var lines []string total := len(m.history.Projects) + 1 start, end := viewport(m.history.DirCursor, total, listH) prefixW := 3 // " " + cur(1) + " " buildDirLine := func(cur, name, cnt string, selected bool) string { cntW := stringWidth(cnt) nameW := max(2, w-prefixW-cntW) name = truncateByWidth(name, nameW) line := " " + cur + " " + padRightByWidth(name, nameW) + cnt sty := style.NormStyle if selected { sty = style.SelStyle } return sty.Render(padRightByWidth(truncateByWidth(line, w), w)) } if start == 0 { totalSess := 0 for _, pd := range m.history.Projects { totalSess += len(pd.Sessions) } cur := " " if m.history.DirCursor == 0 { cur = "▸" } lines = append(lines, buildDirLine(cur, "全部", fmt.Sprintf("(%d)", totalSess), m.history.DirCursor == 0 && m.history.FocusPanel == 0)) } for i := 0; i < len(m.history.Projects); i++ { if i+1 < start || i+1 >= end { continue } pd := m.history.Projects[i] cur := " " if m.history.DirCursor == i+1 { cur = "▸" } lines = append(lines, buildDirLine(cur, pd.DirShort, fmt.Sprintf("(%d)", len(pd.Sessions)), m.history.DirCursor == i+1 && m.history.FocusPanel == 0)) } return lines } func (m *Model) sessColumnLines(w, listH int) []string { sessions := m.currentSessions() if len(sessions) == 0 { return []string{style.SubtitleStyle.Render(fitWidth(" no sessions", w))} } prefixW := 15 // " " + cur(2) + timeStr(11) + " " textW := max(8, w-prefixW) // 预渲染每个 session 为 1~N 行 type sessBlock struct { lines []string } var blocks []sessBlock totalLines := 0 curLineOffset := 0 for i, s := range sessions { cur := " " if i == m.history.SessCursor { cur = "▸ " } timeStr := s.StartTime.Local().Format("01-02 15:04") title := s.DisplayTitle() wrapped := wrapByWidth(title, textW) if len(wrapped) == 0 { wrapped = []string{""} } sty := style.NormStyle if i == m.history.SessCursor && m.history.FocusPanel == 1 { sty = style.SelStyle } var blockLines []string for li, part := range wrapped { var line string if li == 0 { line = fmt.Sprintf(" %s%s %s", cur, timeStr, part) } else { line = strings.Repeat(" ", prefixW) + part } blockLines = append(blockLines, sty.Render(padRightByWidth(truncateByWidth(line, w), w))) } if i == m.history.SessCursor { curLineOffset = totalLines } totalLines += len(blockLines) blocks = append(blocks, sessBlock{lines: blockLines}) } // 按总行数做 viewport startLine, endLine := viewport(curLineOffset, totalLines, listH) var allLines []string for _, b := range blocks { allLines = append(allLines, b.lines...) } if endLine > len(allLines) { endLine = len(allLines) } return allLines[startLine:endLine] } func (m *Model) detailColumnLines(w, listH int) []string { s := m.currentSession() if s == nil { return []string{lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" ← select to view", w))} } sepSty := lipgloss.NewStyle().Foreground(style.BgPanel) var lines []string // Title lines = append(lines, style.DetailTitle.Render(fitWidth(" "+s.DisplayTitle(), w))) // Separator lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) // Key-value rows kvRows := []struct{ k, v string }{ {"dir", s.Cwd}, {"time", fmt.Sprintf("%s ~ %s", s.StartTime.Local().Format("01-02 15:04"), s.EndTime.Local().Format("15:04"))}, {"msgs", fmt.Sprintf("%d", s.MsgCount)}, } for _, r := range kvRows { line := fmt.Sprintf(" %-4s %s", r.k, r.v) lines = append(lines, style.NormStyle.Render(fitWidth(line, w))) } // Summary if summary := s.DisplaySummary(); summary != "" { lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) lines = append(lines, style.SessionSummaryStyle.Render(fitWidth(" "+summary, w))) } // Completed items if len(s.Completed) > 0 { lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) lines = append(lines, lipgloss.NewStyle().Foreground(style.Success).Bold(true).Render(fitWidth(" ✓ 已完成", w))) for _, item := range s.Completed { lines = append(lines, style.NormStyle.Render(fitWidth(" · "+cleanText(item), w))) } } // Pending items if len(s.Pending) > 0 { lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) lines = append(lines, lipgloss.NewStyle().Foreground(style.Warning).Bold(true).Render(fitWidth(" ○ 待办", w))) for _, item := range s.Pending { lines = append(lines, style.NormStyle.Render(fitWidth(" · "+cleanText(item), w))) } } // Hint lines = append(lines, lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" [Enter]resume", w))) if len(lines) > listH { lines = lines[:listH] } return lines } // --- 工具 --- func (m *Model) fmtHelp(key, desc string) string { return lipgloss.NewStyle().Foreground(style.Accent).Render(key) + lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc) } // viewport 返回 [start, end) 区间,保证 cursor 在可见范围内 func viewport(cursor, total, height int) (int, int) { if total <= height { return 0, total } half := height / 2 start := cursor - half if start < 0 { start = 0 } end := start + height if end > total { end = total start = end - height if start < 0 { start = 0 } } return start, end } // stringWidth 返回字符串的终端显示宽度 func stringWidth(s string) int { w := 0 for _, r := range s { w += runeWidth(r) } return w } // padRightByWidth 补空格到指定视觉宽度 func padRightByWidth(s string, targetW int) string { w := stringWidth(s) if w >= targetW { return s } return s + strings.Repeat(" ", targetW-w) } // wrapByWidth 按显示宽度换行,返回多行文本 func wrapByWidth(s string, maxW int) []string { if maxW <= 0 || s == "" { return []string{s} } var lines []string for s != "" { cut := truncateByWidth(s, maxW) lines = append(lines, cut) s = s[len(cut):] } return lines } // fitWidth 截断并填充到精确视觉宽度 func fitWidth(s string, w int) string { return padRightByWidth(truncateByWidth(s, w), w) } // truncateByWidth 按显示宽度截断字符串,不切断多字节字符 func truncateByWidth(s string, maxW int) string { w := 0 for i := 0; i < len(s); { r, size := utf8.DecodeRuneInString(s[i:]) rw := runeWidth(r) if w+rw > maxW { return s[:i] } w += rw i += size } return s } // runeWidth 返回单个 rune 的终端显示宽度 (CJK=2, 其他=1) func runeWidth(r rune) int { if r >= 0x1100 && (r <= 0x115F || r == 0x2329 || r == 0x232A || (r >= 0x2E80 && r <= 0xA4CF && r != 0x303F) || (r >= 0xAC00 && r <= 0xD7A3) || (r >= 0xF900 && r <= 0xFAFF) || (r >= 0xFE10 && r <= 0xFE19) || (r >= 0xFE30 && r <= 0xFE6F) || (r >= 0xFF01 && r <= 0xFF60) || (r >= 0xFFE0 && r <= 0xFFE6) || (r >= 0x20000 && r <= 0x2FFFD) || (r >= 0x30000 && r <= 0x3FFFD)) { return 2 } return 1 } func launchInWtWithTitle(dir, tabTitle, script string) { if runtime.GOOS == "windows" { encoded := encodePSCommand(script) 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, "pwsh", "-NoExit", "-EncodedCommand", encoded) cmd := exec.Command("wt.exe", args...) if err := cmd.Start(); err != nil { log.Printf("[u-tabs] launch fail: %v", err) } } } func (m *Model) LaunchWorkspace(ws Workspace) { script := buildLaunchScript(ws) if runtime.GOOS != "windows" { m.pendingCmd = "cd '" + ws.Dir + "' && " + script return } launchInWtWithTitle(ws.Dir, "", script) } // GetPendingCmd 返回退出后需执行的命令(Linux 用) func (m *Model) GetPendingCmd() string { return m.pendingCmd } func resumeSession(s *Session) { cwd := s.Cwd if runtime.GOOS == "windows" { cwd = strings.ReplaceAll(s.Cwd, "/", "\\") } title := s.CustomTitle if title == "" { title = s.ID[:8] } var script string if runtime.GOOS == "windows" { script = fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "resume: %s" Write-Host "=== Resuming: %s ===" -ForegroundColor Cyan cd "%s" claude -r %s --permission-mode bypassPermissions`, title, title, cwd, s.ID) } else { script = fmt.Sprintf(`printf '\033]0;resume: %s\007' && echo "=== Resuming: %s ===" && cd "%s" && claude -r %s`, title, title, cwd, s.ID) } launchInWtWithTitle(cwd, "", script) } func buildLaunchScript(ws Workspace) string { if runtime.GOOS == "windows" { return fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s" Write-Host "=== %s ===" -ForegroundColor Cyan Write-Host "Prompt: %s" -ForegroundColor Yellow cd "%s" claude --name "%s" --permission-mode bypassPermissions`, ws.Title, ws.Title, ws.Prompt, ws.Dir, ws.Title) } return fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== %s ===" && echo "Prompt: %s" && cd "%s" && claude --name "%s"`, ws.Title, ws.Title, ws.Prompt, ws.Dir, ws.Title) } func encodePSCommand(script string) string { u16 := utf16.Encode([]rune(script)) b := make([]byte, len(u16)*2) for i, r := range u16 { b[i*2] = byte(r) b[i*2+1] = byte(r >> 8) } return base64.StdEncoding.EncodeToString(b) } func randRange(min, max int) int { n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min))) return min + int(n.Int64()) }