Files
u-tabs/internal/app.go
绝尘 0bd9848df9 新增: 会话分叉功能,优化增量扫描缓存
- 新增 c 键触发会话分叉,支持带方向提示的分叉输入
- 使用 claude --fork-session 原生分叉,不污染原会话
- Init() 启动时自动触发历史扫描,无需手动切 tab
- 缓存提升到 HistoryState 内存持有,避免重复 I/O
- 新增项目目录 modTime 增量扫描,未变目录跳过遍历
- 扫描后裁剪缓存,删除磁盘已不存在的条目
- updateSummaryInCache 直接操作内存缓存
2026-05-28 16:18:32 +08:00

1325 lines
34 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 (
"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), 最后一个是历史入口)
forkMode bool // 分叉输入模式
forkBuf string // 分叉方向提示输入
}
func NewModel() *Model {
return &Model{
activeGroup: len(Groups), // 默认打开历史对话
selected: make(map[int]bool),
}
}
func (m *Model) Init() tea.Cmd {
m.update.Checking = true
cmds := []tea.Cmd{CheckUpdateCmd()}
if cmd := m.onTabSwitch(); cmd != nil {
cmds = append(cmds, cmd)
}
return tea.Batch(cmds...)
}
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.Cache = msg.Cache
m.history.Loaded = true
m.history.Scanning = false
if m.history.Favorites == nil {
m.history.Favorites = loadFavorites()
}
// 填充 ForkFrom
forks := loadForks()
if len(forks) > 0 {
for _, pd := range m.history.Projects {
for _, s := range pd.Sessions {
if src, ok := forks[s.ID]; ok {
s.ForkFrom = src
}
}
}
}
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.Scanning {
return nil
}
m.history.Scanning = true
return ScanSessionsCmd()
}
// --- 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()
}
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) {
// fork 输入模式拦截
if m.forkMode {
return m.handleForkInput(s)
}
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":
if cmd := m.onTabSwitch(); cmd != nil {
return m, cmd
}
case "f":
m.toggleFavorite()
case "c":
if m.currentSession() != nil && m.history.FocusPanel == 1 {
m.forkMode = true
m.forkBuf = ""
}
}
return m, nil
}
// handleForkInput 处理分叉方向提示输入
func (m *Model) handleForkInput(s string) (*Model, tea.Cmd) {
switch s {
case "enter":
return m.executeFork()
case "esc":
m.forkMode = false
m.forkBuf = ""
case "backspace":
if len(m.forkBuf) > 0 {
m.forkBuf = m.forkBuf[:len(m.forkBuf)-1]
}
default:
// 只接受可打印字符
if len(s) == 1 && s[0] >= 32 && s[0] < 127 {
m.forkBuf += s
}
}
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
}
func (m *Model) executeFork() (*Model, tea.Cmd) {
s := m.currentSession()
if s == nil {
m.forkMode = false
return m, nil
}
m.forkMode = false
cwd := s.Cwd
if isWindows {
cwd = strings.ReplaceAll(cwd, "/", "\\")
}
title := s.CustomTitle
if title == "" {
title = s.ID[:8]
}
label := "fork: " + title
// 构建 claude -r <id> --fork-session [prompt]
if m.forkBuf != "" {
// 用户输入了方向提示
prompt := m.forkBuf
m.forkBuf = ""
if isWindows {
escaped := strings.ReplaceAll(prompt, `"`, "`\"")
script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "fork: %s"
Write-Host "=== Fork from %s ===" -ForegroundColor Cyan
cd "%s"
claude -r %s --fork-session "%s" --permission-mode bypassPermissions`, title, title, cwd, s.ID, escaped)
launchInWtWithTitle(cwd, label, script)
} else {
escaped := strings.ReplaceAll(prompt, "'", "'\\''")
m.pendingCmd = fmt.Sprintf(`printf '\033]0;fork: %s\007' && echo "=== Fork from %s ===" && cd "%s" && claude -r %s --fork-session '%s' --permission-mode bypassPermissions`, title, title, cwd, s.ID, escaped)
}
} else {
// 纯分叉,不带方向提示
if isWindows {
script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "fork: %s"
Write-Host "=== Fork from %s ===" -ForegroundColor Cyan
cd "%s"
claude -r %s --fork-session --permission-mode bypassPermissions`, title, title, cwd, s.ID)
launchInWtWithTitle(cwd, label, script)
} else {
m.pendingCmd = fmt.Sprintf(`printf '\033]0;fork: %s\007' && echo "=== Fork from %s ===" && cd "%s" && claude -r %s --fork-session --permission-mode bypassPermissions`, title, title, cwd, s.ID)
}
}
m.launched = label
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
updateSummaryInCache(m.history.Cache, 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 "))
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.forkMode {
b.WriteString(style.InputStyle.Render(fmt.Sprintf(" ⎇ fork:%s [Enter]confirm [Esc]cancel", m.forkBuf)))
}
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()
helpStr := " " + strings.Join(helpParts, " ")
verStr := lipgloss.NewStyle().Foreground(style.Dim).Render("绝尘 v" + Version)
padW := m.width - lipgloss.Width(helpStr) - lipgloss.Width(verStr)
if padW > 0 {
b.WriteString(helpStr + strings.Repeat(" ", padW) + verStr)
} else {
b.WriteString(helpStr + " " + verStr)
}
if hint := ConfigHint(); hint != "" {
b.WriteString("\n" + hint)
}
return b.String()
}
func (m *Model) renderHelp() []string {
if m.IsHistoryTab() {
return []string{
m.fmtHelp("↑↓", "sel"),
m.fmtHelp("←→", "panel"),
m.fmtHelp("Enter", "resume"),
m.fmtHelp("f", "star"),
m.fmtHelp("n", "new"),
m.fmtHelp("c", "fork"),
m.fmtHelp("1", "workspace"),
m.fmtHelp("r/F5", "refresh"),
m.fmtHelp("q", "quit"),
}
}
return []string{
m.fmtHelp("←→", "col"),
m.fmtHelp("↑↓", "sel"),
m.fmtHelp("Enter", "run"),
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 s.ForkFrom != "" {
forkID := s.ForkFrom
if len(forkID) > 8 {
forkID = forkID[:8]
}
lines = append(lines, lipgloss.NewStyle().Foreground(style.Cyan).Render(fitWidth(fmt.Sprintf(" ⎇ fork from %s", forkID), 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())
}