Files
u-tabs/internal/app.go

1050 lines
26 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"
)
// 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: 退出后执行的命令
update UpdateState
}
func NewModel() *Model {
return &Model{
activeGroup: 0,
selected: make(map[int]bool),
}
}
func (m *Model) Init() tea.Cmd {
m.update.Checking = true
return CheckUpdateCmd()
}
// 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()
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
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 v" + Version + " "))
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 = ""
}
// update status
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("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())
}