优化: 界面布局对齐与时区修复
This commit is contained in:
554
internal/app.go
554
internal/app.go
@@ -7,7 +7,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"math/big"
|
"math/big"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf16"
|
"unicode/utf16"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -27,6 +29,7 @@ type Model struct {
|
|||||||
height int
|
height int
|
||||||
launched string
|
launched string
|
||||||
history HistoryState
|
history HistoryState
|
||||||
|
pendingCmd string // Linux: 退出后执行的命令
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel() *Model {
|
func NewModel() *Model {
|
||||||
@@ -56,9 +59,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.history.Projects = msg.Projects
|
m.history.Projects = msg.Projects
|
||||||
m.history.Loaded = true
|
m.history.Loaded = true
|
||||||
m.history.Scanning = false
|
m.history.Scanning = false
|
||||||
return m, m.nextSummaryCmd()
|
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:
|
case SummaryResultMsg:
|
||||||
m.applySummary(msg.SessionID, msg.Summary)
|
m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending)
|
||||||
return m, m.nextSummaryCmd()
|
return m, m.nextSummaryCmd()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -168,6 +179,11 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
|
|||||||
} else {
|
} else {
|
||||||
return m.resumeSelected()
|
return m.resumeSelected()
|
||||||
}
|
}
|
||||||
|
case "n":
|
||||||
|
return m.newSessionFromHistory()
|
||||||
|
case "r", "f5":
|
||||||
|
m.history.Scanning = true
|
||||||
|
return m, ScanSessionsCmd()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -177,7 +193,7 @@ func (m *Model) moveHistoryDir(dir int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.history.DirCursor += dir
|
m.history.DirCursor += dir
|
||||||
maxDir := len(m.history.Projects) // 含"全部"
|
maxDir := len(m.history.Projects) + 1 // 含"全部"
|
||||||
if m.history.DirCursor < 0 {
|
if m.history.DirCursor < 0 {
|
||||||
m.history.DirCursor = maxDir - 1
|
m.history.DirCursor = maxDir - 1
|
||||||
}
|
}
|
||||||
@@ -233,15 +249,47 @@ func (m *Model) resumeSelected() (*Model, tea.Cmd) {
|
|||||||
if s == nil {
|
if s == nil {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
go resumeSession(s)
|
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
|
m.launched = "resume: " + s.CustomTitle
|
||||||
return m, nil
|
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 摘要 ---
|
// --- AI 摘要 ---
|
||||||
|
|
||||||
func (m *Model) applySummary(sessionID, summary string) {
|
func (m *Model) applySummary(sessionID, summary string, completed, pending []string) {
|
||||||
if summary == "" {
|
if summary == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -249,7 +297,9 @@ func (m *Model) applySummary(sessionID, summary string) {
|
|||||||
for _, s := range pd.Sessions {
|
for _, s := range pd.Sessions {
|
||||||
if s.ID == sessionID {
|
if s.ID == sessionID {
|
||||||
s.AISummary = summary
|
s.AISummary = summary
|
||||||
saveSummaryToCache(sessionID, summary)
|
s.Completed = completed
|
||||||
|
s.Pending = pending
|
||||||
|
saveSummaryToCache(sessionID, summary, completed, pending)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,7 +309,9 @@ func (m *Model) applySummary(sessionID, summary string) {
|
|||||||
func (m *Model) nextSummaryCmd() tea.Cmd {
|
func (m *Model) nextSummaryCmd() tea.Cmd {
|
||||||
for _, pd := range m.history.Projects {
|
for _, pd := range m.history.Projects {
|
||||||
for _, s := range pd.Sessions {
|
for _, s := range pd.Sessions {
|
||||||
if s.AISummary == "" && s.FilePath != "" && s.FirstMsg != "" {
|
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 generateSummaryCmd(s.FilePath, s.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,7 +351,7 @@ func (m *Model) launchSelected() (*Model, tea.Cmd) {
|
|||||||
var launched []string
|
var launched []string
|
||||||
for idx := range m.selected {
|
for idx := range m.selected {
|
||||||
ws := &AllWorkspaces[idx]
|
ws := &AllWorkspaces[idx]
|
||||||
go launchWorkspace(*ws)
|
m.LaunchWorkspace(*ws)
|
||||||
launched = append(launched, ws.Title)
|
launched = append(launched, ws.Title)
|
||||||
}
|
}
|
||||||
m.launched = strings.Join(launched, ", ")
|
m.launched = strings.Join(launched, ", ")
|
||||||
@@ -309,7 +361,7 @@ func (m *Model) launchSelected() (*Model, tea.Cmd) {
|
|||||||
svcs := WorkspacesByGroup(Groups[m.activeGroup].Label)
|
svcs := WorkspacesByGroup(Groups[m.activeGroup].Label)
|
||||||
if m.cursor < len(svcs) {
|
if m.cursor < len(svcs) {
|
||||||
ws := svcs[m.cursor]
|
ws := svcs[m.cursor]
|
||||||
go launchWorkspace(ws)
|
m.LaunchWorkspace(ws)
|
||||||
m.launched = ws.Title
|
m.launched = ws.Title
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -323,7 +375,7 @@ func (m *Model) launchByInput() (*Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
ws := FindByNumber(num)
|
ws := FindByNumber(num)
|
||||||
if ws != nil {
|
if ws != nil {
|
||||||
go launchWorkspace(*ws)
|
m.LaunchWorkspace(*ws)
|
||||||
m.launched = ws.Title
|
m.launched = ws.Title
|
||||||
}
|
}
|
||||||
m.inputBuf = ""
|
m.inputBuf = ""
|
||||||
@@ -362,7 +414,7 @@ func (m *Model) render() string {
|
|||||||
if ok {
|
if ok {
|
||||||
b.WriteString(lipgloss.NewStyle().
|
b.WriteString(lipgloss.NewStyle().
|
||||||
Bold(true).Background(style.BgPanel).Foreground(gs.GetForeground()).
|
Bold(true).Background(style.BgPanel).Foreground(gs.GetForeground()).
|
||||||
Padding(0, 1).Render(label))
|
Render(label))
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(style.TabActiveStyle.Render(label))
|
b.WriteString(style.TabActiveStyle.Render(label))
|
||||||
}
|
}
|
||||||
@@ -378,7 +430,7 @@ func (m *Model) render() string {
|
|||||||
gs := style.GroupStyles["HISTORY"]
|
gs := style.GroupStyles["HISTORY"]
|
||||||
b.WriteString(lipgloss.NewStyle().
|
b.WriteString(lipgloss.NewStyle().
|
||||||
Bold(true).Background(style.BgPanel).Foreground(gs.GetForeground()).
|
Bold(true).Background(style.BgPanel).Foreground(gs.GetForeground()).
|
||||||
Padding(0, 1).Render(histLabel))
|
Render(histLabel))
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(style.TabInactiveStyle.Render(histLabel))
|
b.WriteString(style.TabInactiveStyle.Render(histLabel))
|
||||||
}
|
}
|
||||||
@@ -420,7 +472,9 @@ func (m *Model) renderHelp() []string {
|
|||||||
m.fmtHelp("j/k", "sel"),
|
m.fmtHelp("j/k", "sel"),
|
||||||
m.fmtHelp("Tab", "→panel"),
|
m.fmtHelp("Tab", "→panel"),
|
||||||
m.fmtHelp("Enter", "resume"),
|
m.fmtHelp("Enter", "resume"),
|
||||||
m.fmtHelp("1-"+fmt.Sprintf("%d", m.totalTabs()), "tab"),
|
m.fmtHelp("n", "new"),
|
||||||
|
m.fmtHelp("1-"+fmt.Sprintf("%d", m.totalTabs()), "tab"),
|
||||||
|
m.fmtHelp("r/F5", "refresh"),
|
||||||
m.fmtHelp("q", "quit"),
|
m.fmtHelp("q", "quit"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -446,10 +500,24 @@ func (m *Model) renderWorkspace() string {
|
|||||||
return style.SubtitleStyle.Render(" empty")
|
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
|
var left strings.Builder
|
||||||
innerW := listW - 4
|
innerW := listW - 4
|
||||||
for i, ws := range svcs {
|
|
||||||
|
// 计算 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 := " "
|
cur := " "
|
||||||
if i == m.cursor {
|
if i == m.cursor {
|
||||||
cur = "▸"
|
cur = "▸"
|
||||||
@@ -459,11 +527,13 @@ func (m *Model) renderWorkspace() string {
|
|||||||
mark = "✓"
|
mark = "✓"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
paddedTitle := padRightByWidth(ws.Title, maxTitleW)
|
||||||
|
|
||||||
if i == m.cursor {
|
if i == m.cursor {
|
||||||
// 选中行:纯文本,让 SelStyle 统一着色
|
// 选中行:纯文本,让 SelStyle 统一着色
|
||||||
prefix := cur + " " + mark + " " + fmt.Sprintf("%02d", ws.N) + " "
|
prefix := cur + " " + mark + " " + fmt.Sprintf("%02d", ws.N) + " "
|
||||||
remainW := max(10, innerW-len(prefix))
|
remainW := max(10, innerW-len(prefix))
|
||||||
text := truncateByWidth(ws.Title+" "+ws.Prompt, remainW)
|
text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW)
|
||||||
left.WriteString(style.SelStyle.Width(innerW).Render(prefix + text))
|
left.WriteString(style.SelStyle.Width(innerW).Render(prefix + text))
|
||||||
} else {
|
} else {
|
||||||
// 非选中行:子样式着色
|
// 非选中行:子样式着色
|
||||||
@@ -474,7 +544,7 @@ func (m *Model) renderWorkspace() string {
|
|||||||
num := style.NumStyle.Render(fmt.Sprintf("%02d", ws.N))
|
num := style.NumStyle.Render(fmt.Sprintf("%02d", ws.N))
|
||||||
prefix := cur + " " + markStr + " " + num + " "
|
prefix := cur + " " + markStr + " " + num + " "
|
||||||
remainW := max(10, innerW-lipgloss.Width(prefix))
|
remainW := max(10, innerW-lipgloss.Width(prefix))
|
||||||
text := truncateByWidth(ws.Title+" "+ws.Prompt, remainW)
|
text := truncateByWidth(paddedTitle+" "+ws.Prompt, remainW)
|
||||||
left.WriteString(style.NormStyle.Render(prefix + text))
|
left.WriteString(style.NormStyle.Render(prefix + text))
|
||||||
}
|
}
|
||||||
left.WriteString("\n")
|
left.WriteString("\n")
|
||||||
@@ -519,7 +589,11 @@ func (m *Model) renderWorkspace() string {
|
|||||||
right.WriteString("\n")
|
right.WriteString("\n")
|
||||||
right.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(" " + strings.Repeat("─", detailW-6)))
|
right.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(" " + strings.Repeat("─", detailW-6)))
|
||||||
right.WriteString("\n")
|
right.WriteString("\n")
|
||||||
right.WriteString(lipgloss.NewStyle().Foreground(style.Accent).Italic(true).Render(" $ wt → CC @" + ws.Title))
|
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("\n")
|
||||||
right.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" [Enter]start [Space]multi"))
|
right.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" [Enter]start [Space]multi"))
|
||||||
} else {
|
} else {
|
||||||
@@ -539,174 +613,224 @@ func (m *Model) renderWorkspace() string {
|
|||||||
// --- HISTORY 三栏渲染 ---
|
// --- HISTORY 三栏渲染 ---
|
||||||
|
|
||||||
func (m *Model) renderHistory() string {
|
func (m *Model) renderHistory() string {
|
||||||
if m.history.Scanning {
|
if !m.history.Loaded && !m.history.Scanning {
|
||||||
return style.ScanningStyle.Render(" scanning sessions...")
|
|
||||||
}
|
|
||||||
if !m.history.Loaded {
|
|
||||||
return style.ScanningStyle.Render(" press Tab to load sessions...")
|
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 {
|
if len(m.history.Projects) == 0 {
|
||||||
return style.SubtitleStyle.Render(" no sessions found")
|
return style.SubtitleStyle.Render(" no sessions found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 三栏宽度分配
|
// 三个独立面板,Width() 含边框,内容宽度 = Width - 2
|
||||||
avail := m.width - 4 // borders + gaps
|
avail := m.width - 8
|
||||||
dirW := max(20, avail*20/100)
|
dirW := max(18, avail*20/100)
|
||||||
sessW := max(30, avail*40/100)
|
sessW := max(28, avail*40/100)
|
||||||
detailW := max(20, avail-dirW-sessW)
|
detailW := avail - dirW - sessW
|
||||||
|
listH := max(3, m.height-7)
|
||||||
|
|
||||||
// 左栏: 目录列表
|
dirCw := dirW - 2
|
||||||
dirBox := m.renderDirPanel(dirW)
|
sessCw := sessW - 2
|
||||||
// 中栏: 会话列表
|
detailCw := detailW - 2
|
||||||
sessBox := m.renderSessPanel(sessW)
|
|
||||||
// 右栏: 详情
|
|
||||||
detailBox := m.renderHistoryDetail(detailW)
|
|
||||||
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Left, dirBox, " ", sessBox, " ", detailBox)
|
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) renderDirPanel(w int) string {
|
func (m *Model) dirColumnLines(w, listH int) []string {
|
||||||
var b strings.Builder
|
var lines []string
|
||||||
innerW := w - 4
|
total := len(m.history.Projects) + 1
|
||||||
|
start, end := viewport(m.history.DirCursor, total, listH)
|
||||||
|
|
||||||
b.WriteString(style.DetailTitle.Render(" dirs "))
|
prefixW := 3 // " " + cur(1) + " "
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// "全部" 行 (index 0)
|
buildDirLine := func(cur, name, cnt string, selected bool) string {
|
||||||
totalSess := 0
|
cntW := stringWidth(cnt)
|
||||||
for _, pd := range m.history.Projects {
|
nameW := max(2, w-prefixW-cntW)
|
||||||
totalSess += len(pd.Sessions)
|
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))
|
||||||
}
|
}
|
||||||
cur := " "
|
|
||||||
if m.history.DirCursor == 0 {
|
|
||||||
cur = "▸"
|
|
||||||
}
|
|
||||||
if m.history.DirCursor == 0 && m.history.FocusPanel == 0 {
|
|
||||||
line := fmt.Sprintf("%s 全部 (%d)", cur, totalSess)
|
|
||||||
b.WriteString(style.SelStyle.Width(innerW).Render(line))
|
|
||||||
} else {
|
|
||||||
cnt := style.DirCountStyle.Render(fmt.Sprintf("(%d)", totalSess))
|
|
||||||
line := fmt.Sprintf("%s 全部 %s", cur, cnt)
|
|
||||||
b.WriteString(style.NormStyle.Render(line))
|
|
||||||
}
|
|
||||||
b.WriteString("`n")
|
|
||||||
|
|
||||||
for i, pd := range m.history.Projects {
|
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 := " "
|
cur := " "
|
||||||
if m.history.DirCursor == i+1 {
|
if m.history.DirCursor == i+1 {
|
||||||
cur = "▸"
|
cur = "▸"
|
||||||
}
|
}
|
||||||
name := truncateByWidth(pd.DirShort, max(8, innerW-8))
|
lines = append(lines, buildDirLine(cur, pd.DirShort, fmt.Sprintf("(%d)", len(pd.Sessions)),
|
||||||
|
m.history.DirCursor == i+1 && m.history.FocusPanel == 0))
|
||||||
if m.history.DirCursor == i+1 && m.history.FocusPanel == 0 {
|
|
||||||
line := fmt.Sprintf("%s %s (%d)", cur, name, len(pd.Sessions))
|
|
||||||
b.WriteString(style.SelStyle.Width(innerW).Render(line))
|
|
||||||
} else {
|
|
||||||
cnt := style.DirCountStyle.Render(fmt.Sprintf("(%d)", len(pd.Sessions)))
|
|
||||||
line := fmt.Sprintf("%s %s %s", cur, name, cnt)
|
|
||||||
b.WriteString(style.NormStyle.Render(line))
|
|
||||||
}
|
|
||||||
b.WriteString("`n")
|
|
||||||
}
|
}
|
||||||
|
return lines
|
||||||
return lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.NormalBorder()).
|
|
||||||
BorderForeground(style.BgPanel).
|
|
||||||
Width(w).
|
|
||||||
Padding(0, 1).
|
|
||||||
Render(b.String())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderSessPanel(w int) string {
|
func (m *Model) sessColumnLines(w, listH int) []string {
|
||||||
var b strings.Builder
|
|
||||||
innerW := w - 4
|
|
||||||
|
|
||||||
b.WriteString(style.DetailTitle.Render(" sessions "))
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
sessions := m.currentSessions()
|
sessions := m.currentSessions()
|
||||||
if len(sessions) == 0 {
|
if len(sessions) == 0 {
|
||||||
b.WriteString(style.SubtitleStyle.Render(" no sessions"))
|
return []string{style.SubtitleStyle.Render(fitWidth(" no sessions", w))}
|
||||||
} else {
|
|
||||||
for i, s := range sessions {
|
|
||||||
cur := " "
|
|
||||||
if i == m.history.SessCursor {
|
|
||||||
cur = "▸"
|
|
||||||
}
|
|
||||||
timeStr := s.StartTime.Format("01-02 15:04")
|
|
||||||
title := s.DisplayTitle()
|
|
||||||
remainW := max(10, innerW-10)
|
|
||||||
text := truncateByWidth(title, remainW)
|
|
||||||
|
|
||||||
if i == m.history.SessCursor && m.history.FocusPanel == 1 {
|
|
||||||
line := fmt.Sprintf("%s %s %s", cur, timeStr, text)
|
|
||||||
b.WriteString(style.SelStyle.Width(innerW).Render(line))
|
|
||||||
} else {
|
|
||||||
line := fmt.Sprintf("%s %s %s", cur, style.SessionTimeStyle.Render(timeStr), text)
|
|
||||||
b.WriteString(style.NormStyle.Render(line))
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return lipgloss.NewStyle().
|
prefixW := 15 // " " + cur(2) + timeStr(11) + " "
|
||||||
Border(lipgloss.NormalBorder()).
|
textW := max(8, w-prefixW)
|
||||||
BorderForeground(style.BgPanel).
|
|
||||||
Width(w).
|
// 预渲染每个 session 为 1~N 行
|
||||||
Padding(0, 1).
|
type sessBlock struct {
|
||||||
Render(b.String())
|
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) renderHistoryDetail(w int) string {
|
func (m *Model) detailColumnLines(w, listH int) []string {
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
s := m.currentSession()
|
s := m.currentSession()
|
||||||
if s == nil {
|
if s == nil {
|
||||||
b.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" ← select to view"))
|
return []string{lipgloss.NewStyle().Foreground(style.Dim).Render(fitWidth(" ← select to view", w))}
|
||||||
} else {
|
|
||||||
b.WriteString(style.DetailTitle.Render(" detail "))
|
|
||||||
b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(strings.Repeat("─", w-12)))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
rows := []struct {
|
|
||||||
key string
|
|
||||||
val string
|
|
||||||
sty lipgloss.Style
|
|
||||||
}{
|
|
||||||
{"title", s.CustomTitle, style.ValStyle},
|
|
||||||
{"dir", s.Cwd, style.ValStyle},
|
|
||||||
{"time", fmt.Sprintf("%s ~ %s", s.StartTime.Format("01-02 15:04"), s.EndTime.Format("15:04")), style.NumStyle},
|
|
||||||
{"msgs", fmt.Sprintf("%d", s.MsgCount), style.SessionMsgCntStyle},
|
|
||||||
}
|
|
||||||
for _, r := range rows {
|
|
||||||
b.WriteString(" ")
|
|
||||||
b.WriteString(style.KeyStyle.Render(r.key))
|
|
||||||
b.WriteString(" ")
|
|
||||||
b.WriteString(r.sty.Render(r.val))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 摘要
|
|
||||||
summary := s.DisplaySummary()
|
|
||||||
if summary != "" {
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(lipgloss.NewStyle().Foreground(style.BgPanel).Render(" " + strings.Repeat("─", w-6)))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(style.SessionSummaryStyle.Render(" " + truncateByWidth(summary, w-6)))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(lipgloss.NewStyle().Foreground(style.Dim).Render(" [Enter]resume"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return lipgloss.NewStyle().
|
sepSty := lipgloss.NewStyle().Foreground(style.BgPanel)
|
||||||
Border(lipgloss.NormalBorder()).
|
var lines []string
|
||||||
BorderForeground(style.BgPanel).
|
|
||||||
Width(w).
|
// Title
|
||||||
Padding(0, 1).
|
lines = append(lines, style.DetailTitle.Render(fitWidth(" "+s.DisplayTitle(), w)))
|
||||||
Render(b.String())
|
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 工具 ---
|
// --- 工具 ---
|
||||||
@@ -716,12 +840,69 @@ func (m *Model) fmtHelp(key, desc string) string {
|
|||||||
lipgloss.NewStyle().Foreground(style.Dim).Render(":" + desc)
|
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 按显示宽度截断字符串,不切断多字节字符
|
// truncateByWidth 按显示宽度截断字符串,不切断多字节字符
|
||||||
func truncateByWidth(s string, maxW int) string {
|
func truncateByWidth(s string, maxW int) string {
|
||||||
w := 0
|
w := 0
|
||||||
for i := 0; i < len(s); {
|
for i := 0; i < len(s); {
|
||||||
_, size := utf8.DecodeRuneInString(s[i:])
|
r, size := utf8.DecodeRuneInString(s[i:])
|
||||||
r := rune(s[i])
|
|
||||||
rw := runeWidth(r)
|
rw := runeWidth(r)
|
||||||
if w+rw > maxW {
|
if w+rw > maxW {
|
||||||
return s[:i]
|
return s[:i]
|
||||||
@@ -750,45 +931,67 @@ func runeWidth(r rune) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 启动 ---
|
func launchInWtWithTitle(dir, tabTitle, script string) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
func launchInWt(dir, script string) {
|
encoded := encodePSCommand(script)
|
||||||
encoded := encodePSCommand(script)
|
color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255))
|
||||||
color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255))
|
args := []string{"-w", "0", "-d", dir, "--tabColor", color}
|
||||||
cmd := exec.Command("wt.exe", "-w", "0",
|
if tabTitle != "" {
|
||||||
"-d", dir,
|
args = append(args, "--title", tabTitle)
|
||||||
"--tabColor", color,
|
}
|
||||||
"pwsh", "-NoExit", "-EncodedCommand", encoded,
|
args = append(args, "pwsh", "-NoExit", "-EncodedCommand", encoded)
|
||||||
)
|
cmd := exec.Command("wt.exe", args...)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
log.Printf("[u-tabs] launch fail: %v", err)
|
log.Printf("[u-tabs] launch fail: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func launchWorkspace(ws Workspace) {
|
func (m *Model) LaunchWorkspace(ws Workspace) {
|
||||||
script := buildLaunchScript(ws)
|
script := buildLaunchScript(ws)
|
||||||
launchInWt(ws.Dir, script)
|
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) {
|
func resumeSession(s *Session) {
|
||||||
cwd := strings.ReplaceAll(s.Cwd, "/", "\\")
|
cwd := s.Cwd
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cwd = strings.ReplaceAll(s.Cwd, "/", "\\")
|
||||||
|
}
|
||||||
title := s.CustomTitle
|
title := s.CustomTitle
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = s.ID[:8]
|
title = s.ID[:8]
|
||||||
}
|
}
|
||||||
script := fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "resume: %s"
|
var script string
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
script = fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "resume: %s"
|
||||||
Write-Host "=== Resuming: %s ===" -ForegroundColor Cyan
|
Write-Host "=== Resuming: %s ===" -ForegroundColor Cyan
|
||||||
cd "%s"
|
cd "%s"
|
||||||
claude -r %s`, title, title, cwd, s.ID)
|
claude -r %s --permission-mode bypassPermissions`, title, title, cwd, s.ID)
|
||||||
launchInWt(cwd, script)
|
} 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 {
|
func buildLaunchScript(ws Workspace) string {
|
||||||
return fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s"
|
if runtime.GOOS == "windows" {
|
||||||
|
return fmt.Sprintf(`$Host.UI.RawUI.WindowTitle = "%s"
|
||||||
Write-Host "=== %s ===" -ForegroundColor Cyan
|
Write-Host "=== %s ===" -ForegroundColor Cyan
|
||||||
Write-Host "Prompt: %s" -ForegroundColor Yellow
|
Write-Host "Prompt: %s" -ForegroundColor Yellow
|
||||||
cd "%s"
|
cd "%s"
|
||||||
claude --name "%s" --permission-mode bypassPermissions`,
|
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)
|
ws.Title, ws.Title, ws.Prompt, ws.Dir, ws.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -806,3 +1009,4 @@ func randRange(min, max int) int {
|
|||||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min)))
|
n, _ := rand.Int(rand.Reader, big.NewInt(int64(max-min)))
|
||||||
return min + int(n.Int64())
|
return min + int(n.Int64())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type Session struct {
|
|||||||
FirstMsg string // 首条用户消息 (截断)
|
FirstMsg string // 首条用户消息 (截断)
|
||||||
AwaySummary string // away_summary 系统摘要
|
AwaySummary string // away_summary 系统摘要
|
||||||
AISummary string // AI 生成的摘要 (缓存)
|
AISummary string // AI 生成的摘要 (缓存)
|
||||||
|
Completed []string // AI 提取的已完成项
|
||||||
|
Pending []string // AI 提取的待办项
|
||||||
FilePath string // JSONL 文件完整路径
|
FilePath string // JSONL 文件完整路径
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +41,10 @@ type ProjectDir struct {
|
|||||||
|
|
||||||
// DisplayTitle 返回会话显示标题
|
// DisplayTitle 返回会话显示标题
|
||||||
func (s *Session) DisplayTitle() string {
|
func (s *Session) DisplayTitle() string {
|
||||||
|
return cleanText(s.displayTitleRaw())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) displayTitleRaw() string {
|
||||||
if s.AISummary != "" {
|
if s.AISummary != "" {
|
||||||
return s.AISummary
|
return s.AISummary
|
||||||
}
|
}
|
||||||
@@ -53,12 +59,27 @@ func (s *Session) DisplayTitle() string {
|
|||||||
|
|
||||||
// DisplaySummary 返回会话摘要
|
// DisplaySummary 返回会话摘要
|
||||||
func (s *Session) DisplaySummary() string {
|
func (s *Session) DisplaySummary() string {
|
||||||
|
return cleanText(s.displaySummaryRaw())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) displaySummaryRaw() string {
|
||||||
if s.AwaySummary != "" {
|
if s.AwaySummary != "" {
|
||||||
return s.AwaySummary
|
return s.AwaySummary
|
||||||
}
|
}
|
||||||
return s.FirstMsg
|
return s.FirstMsg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanText 清理显示文本:多空格保留一个,换行转空格
|
||||||
|
func cleanText(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "\r\n", " ")
|
||||||
|
s = strings.ReplaceAll(s, "\n", " ")
|
||||||
|
s = strings.ReplaceAll(s, "\t", " ")
|
||||||
|
for strings.Contains(s, " ") {
|
||||||
|
s = strings.ReplaceAll(s, " ", " ")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
// HistoryState HISTORY Tab 视图状态
|
// HistoryState HISTORY Tab 视图状态
|
||||||
type HistoryState struct {
|
type HistoryState struct {
|
||||||
Projects []*ProjectDir
|
Projects []*ProjectDir
|
||||||
@@ -77,28 +98,31 @@ func (m *Model) IsHistoryTab() bool {
|
|||||||
// --- 扫描 ---
|
// --- 扫描 ---
|
||||||
|
|
||||||
type ScanCompleteMsg struct {
|
type ScanCompleteMsg struct {
|
||||||
Projects []*ProjectDir
|
Projects []*ProjectDir
|
||||||
|
UpdatedIDs map[string]bool // modTime 变化的会话,需要重新生成摘要
|
||||||
}
|
}
|
||||||
|
|
||||||
func ScanSessionsCmd() tea.Cmd {
|
func ScanSessionsCmd() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
return ScanCompleteMsg{Projects: scanAllProjects()}
|
projects, updated := scanAllProjects()
|
||||||
|
return ScanCompleteMsg{Projects: projects, UpdatedIDs: updated}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanAllProjects() []*ProjectDir {
|
func scanAllProjects() ([]*ProjectDir, map[string]bool) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
projectsDir := filepath.Join(home, ".claude", "projects")
|
projectsDir := filepath.Join(home, ".claude", "projects")
|
||||||
entries, err := os.ReadDir(projectsDir)
|
entries, err := os.ReadDir(projectsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cache := loadCache(home)
|
cache := loadCache(home)
|
||||||
dirMap := make(map[string]*ProjectDir)
|
dirMap := make(map[string]*ProjectDir)
|
||||||
|
updatedIDs := make(map[string]bool)
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
@@ -129,6 +153,11 @@ func scanAllProjects() []*ProjectDir {
|
|||||||
if session == nil || session.Cwd == "" {
|
if session == nil || session.Cwd == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// 清除旧摘要,标记需要重新生成
|
||||||
|
session.AISummary = ""
|
||||||
|
session.Completed = nil
|
||||||
|
session.Pending = nil
|
||||||
|
updatedIDs[sessionID] = true
|
||||||
addSessionToDir(dirMap, session)
|
addSessionToDir(dirMap, session)
|
||||||
cache[sessionID] = cacheEntryFrom(session, info.ModTime())
|
cache[sessionID] = cacheEntryFrom(session, info.ModTime())
|
||||||
}
|
}
|
||||||
@@ -146,7 +175,7 @@ func scanAllProjects() []*ProjectDir {
|
|||||||
sort.Slice(result, func(i, j int) bool {
|
sort.Slice(result, func(i, j int) bool {
|
||||||
return result[i].Dir < result[j].Dir
|
return result[i].Dir < result[j].Dir
|
||||||
})
|
})
|
||||||
return result
|
return result, updatedIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
func addSessionToDir(dirMap map[string]*ProjectDir, s *Session) {
|
func addSessionToDir(dirMap map[string]*ProjectDir, s *Session) {
|
||||||
@@ -174,18 +203,22 @@ func scanSessionFile(path string, sessionID string) *Session {
|
|||||||
scanner := bufio.NewScanner(f)
|
scanner := bufio.NewScanner(f)
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
|
||||||
|
|
||||||
lineCount := 0
|
lineCount := 0
|
||||||
firstMsgFound := false
|
firstMsgFound := false
|
||||||
scannedLines := 0 // 有效行(含 type 的)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
lineCount++
|
lineCount++
|
||||||
line := scanner.Bytes()
|
line := scanner.Bytes()
|
||||||
|
|
||||||
|
// 前 20 行内出现 queue-operation → claude -p 会话,跳过
|
||||||
|
if lineCount <= 20 && matchType(line, "queue-operation") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if !hasType(line) {
|
if !hasType(line) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
scannedLines++
|
|
||||||
|
|
||||||
// cwd — 统一为正斜杠
|
// cwd — 统一为正斜杠
|
||||||
if s.Cwd == "" {
|
if s.Cwd == "" {
|
||||||
@@ -230,13 +263,6 @@ func scanSessionFile(path string, sessionID string) *Session {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按 scannedLines/lineCount 比例估算总消息数
|
|
||||||
if lineCount > scanLineLimit && scannedLines > 0 {
|
|
||||||
ratio := float64(lineCount) / float64(scanLineLimit)
|
|
||||||
s.MsgCount = int(float64(s.MsgCount) * ratio)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +313,7 @@ func extractUserMsg(line []byte) string {
|
|||||||
region := line[searchStart:]
|
region := line[searchStart:]
|
||||||
if tidx := bytes.Index(region, []byte(`"text":"`)); tidx >= 0 {
|
if tidx := bytes.Index(region, []byte(`"text":"`)); tidx >= 0 {
|
||||||
var text string
|
var text string
|
||||||
if extractJSONString(region[tidx+6:], &text) && text != "" &&
|
if extractJSONString(region[tidx+7:], &text) && text != "" &&
|
||||||
!strings.HasPrefix(text, "Base directory") {
|
!strings.HasPrefix(text, "Base directory") {
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
@@ -331,7 +357,7 @@ func findStringEnd(data []byte) int {
|
|||||||
|
|
||||||
// --- 缓存 ---
|
// --- 缓存 ---
|
||||||
|
|
||||||
const cacheVersion = 1
|
const cacheVersion = 2
|
||||||
|
|
||||||
type cacheFile struct {
|
type cacheFile struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
@@ -348,6 +374,8 @@ type cacheEntry struct {
|
|||||||
FirstMsg string `json:"firstMsg"`
|
FirstMsg string `json:"firstMsg"`
|
||||||
AwaySummary string `json:"awaySummary"`
|
AwaySummary string `json:"awaySummary"`
|
||||||
AISummary string `json:"aiSummary"`
|
AISummary string `json:"aiSummary"`
|
||||||
|
Completed []string `json:"completed"`
|
||||||
|
Pending []string `json:"pending"`
|
||||||
FilePath string `json:"filePath"`
|
FilePath string `json:"filePath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,6 +385,7 @@ func (e *cacheEntry) ToSession(id string) *Session {
|
|||||||
StartTime: e.StartTime, EndTime: e.EndTime,
|
StartTime: e.StartTime, EndTime: e.EndTime,
|
||||||
MsgCount: e.MsgCount, FirstMsg: e.FirstMsg,
|
MsgCount: e.MsgCount, FirstMsg: e.FirstMsg,
|
||||||
AwaySummary: e.AwaySummary, AISummary: e.AISummary,
|
AwaySummary: e.AwaySummary, AISummary: e.AISummary,
|
||||||
|
Completed: e.Completed, Pending: e.Pending,
|
||||||
FilePath: e.FilePath,
|
FilePath: e.FilePath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,6 +396,7 @@ func cacheEntryFrom(s *Session, modTime time.Time) *cacheEntry {
|
|||||||
StartTime: s.StartTime, EndTime: s.EndTime,
|
StartTime: s.StartTime, EndTime: s.EndTime,
|
||||||
MsgCount: s.MsgCount, FirstMsg: s.FirstMsg,
|
MsgCount: s.MsgCount, FirstMsg: s.FirstMsg,
|
||||||
AwaySummary: s.AwaySummary, AISummary: s.AISummary,
|
AwaySummary: s.AwaySummary, AISummary: s.AISummary,
|
||||||
|
Completed: s.Completed, Pending: s.Pending,
|
||||||
FilePath: s.FilePath,
|
FilePath: s.FilePath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -416,39 +446,63 @@ func truncStr(s string, maxRunes int) string {
|
|||||||
type SummaryResultMsg struct {
|
type SummaryResultMsg struct {
|
||||||
SessionID string
|
SessionID string
|
||||||
Summary string
|
Summary string
|
||||||
|
Completed []string
|
||||||
|
Pending []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateSummaryCmd(filePath, sessionID string) tea.Cmd {
|
func generateSummaryCmd(filePath, sessionID string) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
summary := generateAISummary(filePath)
|
sum, done, todo := generateAISummary(filePath)
|
||||||
return SummaryResultMsg{SessionID: sessionID, Summary: summary}
|
return SummaryResultMsg{SessionID: sessionID, Summary: sum, Completed: done, Pending: todo}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateAISummary(filePath string) string {
|
type aiSummaryResult struct {
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Completed []string `json:"completed"`
|
||||||
|
Pending []string `json:"pending"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateAISummary(filePath string) (string, []string, []string) {
|
||||||
messages := extractMessagesForSummary(filePath)
|
messages := extractMessagesForSummary(filePath)
|
||||||
if messages == "" {
|
if messages == "" {
|
||||||
return ""
|
return "", nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
prompt := "请用20字以内中文概括以下对话的主题,只输出概括文字,不加引号、标点或markdown格式:\n\n" + messages
|
prompt := `分析以下Claude Code对话,用JSON输出:
|
||||||
|
{"summary":"20字内中文概括","completed":["已完成项1","已完成项2"],"pending":["待办项1"]}
|
||||||
|
要求:summary 20字内概括;completed 已完成工作(每项≤30字);pending 未完成/待办(每项≤30字)。只输出JSON。` + "\n\n" + messages
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "claude", "-p", prompt)
|
cmd := exec.CommandContext(ctx, "claude", "-p", prompt)
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return "", nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
summary := strings.TrimSpace(string(output))
|
raw := strings.TrimSpace(string(output))
|
||||||
summary = strings.Trim(summary, "\"'`*")
|
raw = strings.Trim(raw, "`")
|
||||||
summary = strings.TrimPrefix(summary, "概括:")
|
raw = strings.TrimPrefix(raw, "json")
|
||||||
summary = strings.TrimPrefix(summary, "主题:")
|
raw = strings.TrimSpace(raw)
|
||||||
if len(summary) > 60 {
|
|
||||||
summary = truncStr(summary, 60)
|
var result aiSummaryResult
|
||||||
|
if json.Unmarshal([]byte(raw), &result) != nil {
|
||||||
|
// Fallback: treat whole output as summary
|
||||||
|
s := strings.Trim(raw, "\"'`*")
|
||||||
|
s = strings.TrimPrefix(s, "概括:")
|
||||||
|
s = strings.TrimPrefix(s, "主题:")
|
||||||
|
if len(s) > 60 {
|
||||||
|
s = truncStr(s, 60)
|
||||||
|
}
|
||||||
|
return s, nil, nil
|
||||||
}
|
}
|
||||||
return summary
|
|
||||||
|
if len(result.Summary) > 60 {
|
||||||
|
result.Summary = truncStr(result.Summary, 60)
|
||||||
|
}
|
||||||
|
return result.Summary, result.Completed, result.Pending
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractMessagesForSummary(path string) string {
|
func extractMessagesForSummary(path string) string {
|
||||||
@@ -482,7 +536,7 @@ func extractMessagesForSummary(path string) string {
|
|||||||
return strings.Join(msgs, "\n")
|
return strings.Join(msgs, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveSummaryToCache(sessionID, summary string) {
|
func saveSummaryToCache(sessionID, summary string, completed, pending []string) {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -493,5 +547,7 @@ func saveSummaryToCache(sessionID, summary string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
entry.AISummary = summary
|
entry.AISummary = summary
|
||||||
|
entry.Completed = completed
|
||||||
|
entry.Pending = pending
|
||||||
saveCache(home, cache)
|
saveCache(home, cache)
|
||||||
}
|
}
|
||||||
|
|||||||
16
main.go
16
main.go
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
"charm.land/bubbletea/v2"
|
"charm.land/bubbletea/v2"
|
||||||
"u-tabs/internal"
|
"u-tabs/internal"
|
||||||
@@ -16,4 +18,18 @@ func main() {
|
|||||||
fmt.Fprintf(os.Stderr, "启动失败: %v\n", err)
|
fmt.Fprintf(os.Stderr, "启动失败: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 非 Windows: 退出后执行待运行的命令
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd := m.GetPendingCmd()
|
||||||
|
if cmd == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sh := exec.Command("bash", "-c", cmd)
|
||||||
|
sh.Stdin = os.Stdin
|
||||||
|
sh.Stdout = os.Stdout
|
||||||
|
sh.Stderr = os.Stderr
|
||||||
|
sh.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user