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" ) var isWindows = runtime.GOOS == "windows" // Model 主模型 type Model struct { activeGroup int cursor int selected map[int]bool inputBuf string width int height int launched string history HistoryState pendingCmd string update UpdateState wsFocus int // workspace 焦点列: 0=tabs 1=list wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口) } func NewModel() *Model { return &Model{ activeGroup: 0, selected: make(map[int]bool), } } func (m *Model) Init() tea.Cmd { m.update.Checking = true return CheckUpdateCmd() } 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 if m.history.Favorites == nil { m.history.Favorites = loadFavorites() } 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() case UpdateAvailableMsg: m.update.Checking = false m.update.Available = true m.update.NewVersion = msg.NewVersion m.update.Changelog = msg.Changelog m.update.DownloadURL = msg.DownloadURL m.update.SHA256 = msg.SHA256 m.update.FileSize = msg.FileSize case UpdateCompleteMsg: m.update.Updating = false m.update.Done = true m.update.Available = false case UpdateErrorMsg: m.update.Updating = false m.update.Error = msg.Err m.update.Checking = false } return m, nil } // --- 按键分发 --- func (m *Model) handleKey(s string) (*Model, tea.Cmd) { switch s { case "q", "ctrl+c": return m, tea.Quit case "u": if m.update.Available && !m.update.Updating { m.update.Updating = true return m, SelfUpdateCmd(m.update.DownloadURL, m.update.SHA256, m.update.NewVersion) } } // Tab 快捷切换: 1=工作空间 2=历史对话 if s == "1" { if m.IsHistoryTab() { m.activeGroup = 0 m.resetCursor() m.wsFocus = 1 } return m, nil } if s == "2" { if !m.IsHistoryTab() { m.activeGroup = len(Groups) // HISTORY m.resetCursor() m.wsFocus = 1 return m, m.onTabSwitch() } return m, nil } if m.IsHistoryTab() { return m.handleHistoryKey(s) } 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 按键 --- func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) { switch s { case "left", "h": if m.wsFocus > 0 { m.wsFocus = 0 } case "right", "l": if m.wsFocus < 1 { m.wsFocus = 1 } case "tab", "shift+tab": m.wsFocus = (m.wsFocus + 1) % 2 case "up", "k": if m.wsFocus == 0 { m.moveGroupCursor(-1) } else { m.moveCursor(-1) } case "down", "j": if m.wsFocus == 0 { m.moveGroupCursor(1) } else { m.moveCursor(1) } case "enter": if m.inputBuf != "" { return m.launchByInput() } if m.wsFocus == 0 { if m.wsTabCur >= len(Groups) { m.activeGroup = len(Groups) m.resetCursor() return m, m.onTabSwitch() } m.activeGroup = m.wsTabCur m.wsFocus = 1 } else { return m.launchSelected() } case " ": if m.wsFocus == 1 { m.toggleMultiSelect() } default: if len(s) == 1 && s[0] >= '0' && s[0] <= '9' { m.inputBuf += s } } return m, nil } func (m *Model) resetCursor() { m.cursor = 0 m.inputBuf = "" } func (m *Model) moveGroupCursor(dir int) { total := len(Groups) + 1 // 含历史入口 m.wsTabCur = (m.wsTabCur + dir + total) % total if m.wsTabCur < len(Groups) { m.activeGroup = m.wsTabCur } m.resetCursor() } // --- HISTORY 按键 --- 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 { if m.history.DirCursor == len(m.history.Projects)+2 { // 回到工作空间 m.activeGroup = 0 m.resetCursor() m.wsTabCur = 0 m.wsFocus = 1 } m.history.FocusPanel = 1 } else { return m.resumeSelected() } case "n": return m.newSessionFromHistory() case "r", "f5": m.history.Scanning = true return m, ScanSessionsCmd() case "f": m.toggleFavorite() } 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) + 3 // 全部 + 收藏 + 项目 + 回到工作空间 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 m.history.DirCursor == 0 { var all []*Session for _, pd := range m.history.Projects { all = append(all, pd.Sessions...) } return all } if m.history.DirCursor == 1 { // 收藏过滤 return collectFavorites(m.history.Projects, m.history.Favorites) } idx := m.history.DirCursor - 2 if idx >= len(m.history.Projects) { return nil } return m.history.Projects[idx].Sessions } func (m *Model) currentSession() *Session { sessions := m.currentSessions() if len(sessions) == 0 { return nil } if m.history.SessCursor >= len(sessions) { m.history.SessCursor = len(sessions) - 1 } return sessions[m.history.SessCursor] } func (m *Model) resumeSelected() (*Model, tea.Cmd) { s := m.currentSession() if isWindows { go resumeSession(s) } else { 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, s.Cwd, s.ID) } m.launched = "resume: " + s.CustomTitle return m, nil } // currentDirCwd 取当前选中目录路径,"全部" 返回空 func (m *Model) currentDirCwd() string { if m.history.DirCursor <= 1 { return "" } idx := m.history.DirCursor - 2 if idx >= len(m.history.Projects) { return "" } return m.history.Projects[idx].Dir } 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 } if isWindows { cwd = strings.ReplaceAll(cwd, "/", "\\") } title := filepath.Base(cwd) + " - new" if isWindows { 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 } func (m *Model) toggleFavorite() { if m.history.Favorites == nil { m.history.Favorites = loadFavorites() } s := m.currentSession() if s == nil { return } if m.history.Favorites[s.ID] { delete(m.history.Favorites, s.ID) } else { m.history.Favorites[s.ID] = true } saveFavorites(m.history.Favorites) } // --- 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 } // renderTabBar 顶部两个主 tab:工作空间 / 历史对话 func (m *Model) renderTabBar() string { var b strings.Builder b.WriteString(style.TitleStyle.Render(" u-tabs v" + Version + " ")) sep := style.TabSep.Render(" │ ") // Tab 1: 工作空间 wsLabel := " 1 工作空间 " if !m.IsHistoryTab() { b.WriteString(lipgloss.NewStyle(). Bold(true).Background(style.BgPanel).Foreground(style.Accent). Render(wsLabel)) } else { b.WriteString(style.TabInactiveStyle.Render(wsLabel)) } // Tab 2: 历史对话 b.WriteString(sep) histLabel := " 2 历史对话 " if m.IsHistoryTab() { b.WriteString(lipgloss.NewStyle(). Bold(true).Background(style.BgPanel).Foreground(style.Cyan). Render(histLabel)) } else { b.WriteString(style.TabInactiveStyle.Render(histLabel)) } return b.String() } func (m *Model) render() string { if m.width == 0 { return "loading..." } var b strings.Builder // ── header: tab 导航条 + separator ── b.WriteString(m.renderTabBar()) 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 = "" } if m.update.Done { b.WriteString(lipgloss.NewStyle().Foreground(style.Success).Bold(true). Render(fmt.Sprintf(" updated to v%s, restart to apply", m.update.NewVersion))) } else if m.update.Error != nil { b.WriteString(lipgloss.NewStyle().Foreground(style.Red). Render(fmt.Sprintf(" update failed: %v", m.update.Error))) } else if m.update.Updating { b.WriteString(lipgloss.NewStyle().Foreground(style.Warning). Render(" updating...")) } else if m.update.Available { b.WriteString(lipgloss.NewStyle().Foreground(style.Warning).Bold(true). Render(fmt.Sprintf(" v%s available [u]update", m.update.NewVersion))) } 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("←→", "panel"), m.fmtHelp("Enter", "resume"), m.fmtHelp("f", "star"), m.fmtHelp("n", "new"), m.fmtHelp("1", "workspace"), m.fmtHelp("r/F5", "refresh"), m.fmtHelp("q", "quit"), } } return []string{ m.fmtHelp("←→", "col"), m.fmtHelp("j/k", "sel"), m.fmtHelp("Enter", "run"), m.fmtHelp("Space", "multi"), m.fmtHelp("2", "history"), m.fmtHelp("q", "quit"), } } // --- Workspace 三栏渲染 --- func (m *Model) renderWorkspace() string { g := Groups[m.activeGroup] svcs := WorkspacesByGroup(g.Label) if len(svcs) == 0 { return style.SubtitleStyle.Render(" empty") } avail := m.width - 8 tabW := max(14, min(20, avail*18/100)) listW := max(30, avail*45/100) detailW := avail - tabW - listW tabCw := tabW - 2 listCw := listW - 2 detailCw := detailW - 2 listH := max(3, m.height-7) tabLines := m.tabsColumnLines(tabCw, listH-1) svcLines := m.wsListLines(g, svcs, listCw, listH-1) detailLines := m.wsDetailLines(svcs, detailCw, listH-1) maxRows := min(listH-1, max(max(len(tabLines), len(svcLines)), len(detailLines))) tabBorderFg := style.BgPanel if m.wsFocus == 0 { tabBorderFg = style.Accent } listBorderFg := style.BgPanel if m.wsFocus == 1 { listBorderFg = style.Accent } panelSty := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()) tabBox := panelSty.BorderForeground(tabBorderFg).Width(tabW).Render( style.DetailTitle.Render("tabs") + "\n" + padLinesTo(tabLines, tabCw, maxRows)) listBox := panelSty.BorderForeground(listBorderFg).Width(listW).Render( style.DetailTitle.Render(g.Label) + "\n" + padLinesTo(svcLines, listCw, maxRows)) detailBox := panelSty.BorderForeground(style.BgPanel).Width(detailW).Render( style.DetailTitle.Render("detail") + "\n" + padLinesTo(detailLines, detailCw, maxRows)) return lipgloss.JoinHorizontal(lipgloss.Top, tabBox, " ", listBox, " ", detailBox) } // tabsColumnLines 左侧 tabs 列(工作空间分组 + 历史入口) func (m *Model) tabsColumnLines(w, listH int) []string { var lines []string total := len(Groups) + 1 // 含历史入口 start, end := viewport(m.wsTabCur, total, listH) focused := m.wsFocus == 0 for i := start; i < end; i++ { if i == len(Groups) { renderNavEntry(&lines, w, "历史对话", "#7dcfff", m.wsTabCur == i, focused) continue } g := Groups[i] label := g.Label gs := style.GroupStyles[g.Label] isActive := m.wsTabCur == i if isActive { line := " ▸ " + label line = padRightByWidth(truncateByWidth(line, w), w) if focused && gs.GetForeground() != (lipgloss.Color("")) { sty := lipgloss.NewStyle().Foreground(style.BgDark).Background(gs.GetForeground()).Bold(true) lines = append(lines, sty.Render(line)) } else if gs.GetForeground() != (lipgloss.Color("")) { sty := lipgloss.NewStyle().Foreground(gs.GetForeground()).Bold(true) lines = append(lines, sty.Render(line)) } else { lines = append(lines, style.SelStyle.Render(line)) } } else { line := " " + label line = padRightByWidth(truncateByWidth(line, w), w) if gs.GetForeground() != (lipgloss.Color("")) { lines = append(lines, gs.Render(line)) } else { lines = append(lines, style.NormStyle.Render(line)) } } } return lines } func (m *Model) wsListLines(g Group, svcs []Workspace, w, listH int) []string { start, end := viewport(m.cursor, len(svcs), listH) maxTitleW := 0 for _, ws := range svcs { if tw := stringWidth(ws.Title); tw > maxTitleW { maxTitleW = tw } } var lines []string 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 { prefix := cur + " " + mark + " " + fmt.Sprintf("%02d", ws.N) + " " remainW := max(10, w-stringWidth(prefix)) text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW) sty := style.SelStyle if m.wsFocus != 1 { sty = lipgloss.NewStyle().Foreground(style.Accent).Bold(true) } lines = append(lines, sty.Render(padRightByWidth(truncateByWidth(prefix+text, w), w))) } 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, w-stringWidth(prefix)) text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW) lines = append(lines, style.NormStyle.Render(padRightByWidth(truncateByWidth(prefix+text, w), w))) } } return lines } func (m *Model) wsDetailLines(svcs []Workspace, w, listH int) []string { if m.cursor >= len(svcs) { return []string{lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" ← select to view", w))} } ws := svcs[m.cursor] sepSty := lipgloss.NewStyle().Foreground(style.BgPanel) var lines []string lines = append(lines, style.DetailTitle.Render(fitWidth(" "+ws.Title, w))) lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) 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 { line := fmt.Sprintf(" %-6s %s", r.key, r.sty.Render(r.val)) lines = append(lines, style.NormStyle.Render(fitWidth(line, w))) } lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) hintCmd := "wt" if !isWindows { hintCmd = "bash" } lines = append(lines, lipgloss.NewStyle().Foreground(style.Accent).Italic(true).Render(fitWidth(" $ "+hintCmd+" → CC @"+ws.Title, w))) lines = append(lines, lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" [Enter]start [Space]multi", w))) if len(lines) > listH { lines = lines[:listH] } return lines } // --- 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") } 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))) panelSty := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(style.BgPanel) dirBox := panelSty.Width(dirW).Render( style.DetailTitle.Render("dirs") + "\n" + padLinesTo(dirLines, dirCw, maxRows)) sessBox := panelSty.Width(sessW).Render( style.DetailTitle.Render("sessions") + "\n" + padLinesTo(sessLines, sessCw, maxRows)) detailBox := panelSty.Width(detailW).Render( style.DetailTitle.Render("detail") + "\n" + padLinesTo(detailLines, detailCw, maxRows)) return lipgloss.JoinHorizontal(lipgloss.Top, dirBox, " ", sessBox, " ", detailBox) } func (m *Model) dirColumnLines(w, listH int) []string { var lines []string total := len(m.history.Projects) + 3 // 全部 + 收藏 + 项目 + 回到工作空间 start, end := viewport(m.history.DirCursor, total, listH) prefixW := 3 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)) } // 0: 全部 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)) } // 1: 收藏 if start <= 1 && end > 1 { favCnt := countFavorites(m.history.Projects, m.history.Favorites) cur := " " if m.history.DirCursor == 1 { cur = "▸" } lines = append(lines, buildDirLine(cur, "★ 收藏", fmt.Sprintf("(%d)", favCnt), m.history.DirCursor == 1 && m.history.FocusPanel == 0)) } // 2..N+1: 项目目录 for i := 0; i < len(m.history.Projects); i++ { idx := i + 2 if idx < start || idx >= end { continue } pd := m.history.Projects[i] cur := " " if m.history.DirCursor == idx { cur = "▸" } lines = append(lines, buildDirLine(cur, pd.DirShort, fmt.Sprintf("(%d)", len(pd.Sessions)), m.history.DirCursor == idx && m.history.FocusPanel == 0)) } // N+2: 回到工作空间入口 backIdx := len(m.history.Projects) + 2 if end > backIdx { renderNavEntry(&lines, w, "工作空间", "#7aa2f7", m.history.DirCursor == backIdx, 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 := 16 textW := max(8, w-prefixW) 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") favMark := " " if m.history.Favorites[s.ID] { favMark = "★" } 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 %s", cur, timeStr, favMark, part) } else { line = strings.Repeat(" ", prefixW+1) + 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}) } 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 favPrefix := " " if m.history.Favorites[s.ID] { favPrefix = "★ " } lines = append(lines, style.DetailTitle.Render(fitWidth(favPrefix+s.DisplayTitle(), w))) lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) fav := "-" if m.history.Favorites[s.ID] { fav = "★" } 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)}, {"fav", fav}, } for _, r := range kvRows { line := fmt.Sprintf(" %-4s %s", r.k, r.v) lines = append(lines, style.NormStyle.Render(fitWidth(line, w))) } if summary := s.DisplaySummary(); summary != "" { lines = append(lines, sepSty.Render(fitWidth(strings.Repeat("─", w), w))) lines = append(lines, style.SessionSummaryStyle.Render(fitWidth(" "+summary, w))) } 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))) } } 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))) } } 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) } func padLinesTo(lines []string, w, target int) string { for len(lines) < target { lines = append(lines, strings.Repeat(" ", w)) } return strings.Join(lines, "\n") } 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 } func stringWidth(s string) int { w := 0 for _, r := range s { w += runeWidth(r) } return w } func padRightByWidth(s string, targetW int) string { w := stringWidth(s) if w >= targetW { return s } return s + strings.Repeat(" ", targetW-w) } 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 } func fitWidth(s string, w int) string { return padRightByWidth(truncateByWidth(s, w), w) } func renderNavEntry(lines *[]string, w int, label string, activeFg string, isCur, focused bool) { sep := lipgloss.NewStyle().Foreground(style.BgPanel).Render(fitWidth(" ─────────", w)) *lines = append(*lines, sep) if isCur { curLabel := " → " + label line := padRightByWidth(truncateByWidth(curLabel, w), w) if focused { *lines = append(*lines, style.SelStyle.Render(line)) } else { *lines = append(*lines, lipgloss.NewStyle().Foreground(lipgloss.Color(activeFg)).Bold(true).Render(line)) } } else { inactiveLabel := " " + label line := padRightByWidth(truncateByWidth(inactiveLabel, w), w) *lines = append(*lines, lipgloss.NewStyle().Foreground(style.Dim).Render(line)) } } 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 } 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) { 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 !isWindows { m.pendingCmd = "cd '" + ws.Dir + "' && " + script return } launchInWtWithTitle(ws.Dir, "", script) } func (m *Model) GetPendingCmd() string { return m.pendingCmd } func resumeSession(s *Session) { cwd := s.Cwd if isWindows { cwd = strings.ReplaceAll(s.Cwd, "/", "\\") } title := s.CustomTitle if title == "" { title = s.ID[:8] } var script string if isWindows { 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 isWindows { 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()) }