Compare commits

..
7 Commits
Author SHA1 Message Date
lxy cc58de4661 修复: 重命名时缓存缺条目补建防标题丢失
- 缓存缺 entry(缓存损坏或新会话未入缓存)时补建,标题才能落盘
- 补缓存补建测试,顺带补齐测试文件缺失的换行
2026-10-03 10:13:40 +08:00
lxy 558b676eea 修复: 技能面板视口按行收口与截断尾部乱码
- 技能列表视口改按行数切片(含描述行),修复变长条目撑爆面板
- 大文件截断改按 rune 边界回退,修复中文内容尾部乱码
- 补视口与截断边界测试
2026-10-03 10:13:35 +08:00
lxy d54d0c4c63 新增: skills 视图与标题编辑测试(未落库功能批量入库) 2026-08-23 18:25:02 +08:00
lxy 95f349bcd7 新增: 历史会话标题自定义编辑与摘要质量修复
- 标题自定义编辑:HISTORY 详情按 e 编辑标题,UserTitle 持久化到缓存且最高优先级
- AI 摘要质量修复:困惑回复检测词表收紧,消息提取过滤噪音,修复 JSON 包裹困惑回复漏网
- 数据维护命令:--fix-titles 清理低质量摘要,--regen-summaries 并发生成
- 修复代码审查 9 个问题:退格 UTF-8 损坏、中文输入、flush 调度、误杀合法摘要、
  按键全局拦截、UserTitle 未同步窗口标题、wt 启动回落、cmd 注入防护、前缀误隐藏会话
- 启动链 wt.exe 回退与 cmd start 标题转义
- gofmt 规范化,新增 launch_test.go
2026-07-31 21:22:17 +08:00
lxy 043a7d9227 修复: 同事机器无 pwsh 启动失败 0x80070002
- 探测优先 pwsh (PS7),找不到回退 powershell (PS5,Windows 自带)
- 脚本仅用兼容语法 (Write-Host/cd),PS5 可直接运行
2026-07-06 16:53:49 +08:00
lxy ec2603444d 新增: 忽略 vendor 目录 2026-07-01 01:31:29 +08:00
lxy 45e32193af 修复: 缓存原子写与 fork 关系持久化,优化摘要重试
- 原子写: 新增 atomicWriteFile,缓存/收藏/forks 改为临时文件+rename,避免崩溃损坏
- fork 关系: executeFork 记录待绑定项,扫描后按 cwd+时间窗口匹配新会话并持久化
- 摘要重试: 去掉 needDetail 反复刷新逻辑,仅对未生成摘要的会话触发,避免浪费 claude -p 调用
- 脚本转义: pwsh/bash 的 Prompt 回显段转义双引号与 $,防止注入和语法错误
- 输入限制: 工作空间数字输入限制 4 位,避免无限增长
- 品牌统一: 底部版本号文案 绝尘 → U-TABS
2026-07-01 01:29:01 +08:00
20 changed files with 2718 additions and 157 deletions
+3
View File
@@ -5,6 +5,9 @@ u-tabs-linux
whale-windows-amd64.exe whale-windows-amd64.exe
dist/ dist/
# Vendor (依赖由 go.mod 管理,不纳入版本控制)
vendor/
# Config # Config
config.yaml config.yaml
+103 -22
View File
@@ -3,33 +3,39 @@ package internal
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"time" "time"
"charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
) )
// Model 主模型 // Model 主模型
type Model struct { type Model struct {
activeGroup int activeGroup int
cursor int cursor int
selected map[int]bool selected map[int]bool
inputBuf string inputBuf string
width int width int
height int height int
launched string launched string
history HistoryState history HistoryState
pendingCmd string pendingCmd string
update UpdateState update UpdateState
wsFocus int // workspace 焦点列: 0=tabs 1=list wsFocus int // workspace 焦点列: 0=tabs 1=list
wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口) wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口)
forkMode bool // 分叉输入模式 Skills SkillsState
forkBuf string // 分叉方向提示输入 forkMode bool // 分叉输入模式
forkBuf string // 分叉方向提示输入
editTitleMode bool // 编辑标题模式
editTitleBuf string // 标题编辑缓冲区
editTitleCursor int // 标题光标位置(rune 索引)
// 性能缓存 // 性能缓存
allSessionsCache []*Session // "全部" tab 会话切片缓存 allSessionsCache []*Session // "全部" tab 会话切片缓存
allSessionsDirty bool // 缓存是否需要重建 allSessionsDirty bool // 缓存是否需要重建
cacheDirty bool // session-cache.json 是否需要写盘 cacheDirty bool // session-cache.json 是否需要写盘
summaryCount int // 本次会话已生成的摘要数 summaryCount int // 本次会话已生成的摘要数
pendingForks []pendingFork // executeFork 留下的待绑定项,扫描后解析
} }
func NewModel() *Model { func NewModel() *Model {
@@ -56,8 +62,29 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
case ScanCompleteMsg: case ScanCompleteMsg:
// 保留尚未落盘的用户自定义标题:扫描从磁盘读缓存,若改名后 3s 内
// 触发扫描(切 tab/刷新),磁盘旧缓存会覆盖内存导致改名丢失。
savedTitles := map[string]string{}
for id, e := range m.history.Cache {
if e.UserTitle != "" {
savedTitles[id] = e.UserTitle
}
}
m.history.Projects = msg.Projects m.history.Projects = msg.Projects
m.history.Cache = msg.Cache m.history.Cache = msg.Cache
for id, title := range savedTitles {
if e, ok := m.history.Cache[id]; ok && e.UserTitle == "" {
e.UserTitle = title
m.cacheDirty = true
}
for _, pd := range m.history.Projects {
for _, s := range pd.Sessions {
if s.ID == id {
s.UserTitle = title
}
}
}
}
m.history.Loaded = true m.history.Loaded = true
m.history.Scanning = false m.history.Scanning = false
m.allSessionsDirty = true m.allSessionsDirty = true
@@ -76,14 +103,28 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
} }
// 处理 executeFork 留下的待绑定 fork
if len(m.pendingForks) > 0 {
resolvePendingForks(m.pendingForks, m.history.Projects)
m.pendingForks = nil
m.cacheDirty = true
}
total := 0 total := 0
for _, pd := range msg.Projects { for _, pd := range msg.Projects {
total += len(pd.Sessions) total += len(pd.Sessions)
} }
m.launched = fmt.Sprintf("refreshed: %d projects, %d sessions", len(msg.Projects), total) m.launched = fmt.Sprintf("refreshed: %d projects, %d sessions", len(msg.Projects), total)
if len(msg.UpdatedIDs) > 0 { // 无论本次是否有文件更新,都尝试补齐缺失的摘要
return m, m.nextSummaryCmd() return m, m.nextSummaryCmd()
} case ScanSkillsCompleteMsg:
m.Skills.Skills = msg.Skills
m.Skills.Loaded = true
m.Skills.Scanning = false
m.Skills.SkillCur = 0
m.Skills.FileCur = 0
m.Skills.FocusPanel = 0
m.Skills.Content = ""
m.Skills.ContentPath = ""
return m, nil return m, nil
case SummaryResultMsg: case SummaryResultMsg:
m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending) m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending)
@@ -114,6 +155,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil return m, nil
case tea.QuitMsg: case tea.QuitMsg:
// 最终落盘:扫描未完成时 Cache 可能为 nil,此时无数据可写,
// 已生成的摘要会在下次启动扫描时由 cache 维护
m.flushCache() m.flushCache()
return m, tea.Quit return m, tea.Quit
} }
@@ -124,6 +167,44 @@ func (m *Model) GetPendingCmd() string {
return m.pendingCmd return m.pendingCmd
} }
func (m *Model) confirmEditTitle() (*Model, tea.Cmd) {
s := m.currentSession()
title := strings.TrimSpace(m.editTitleBuf)
m.editTitleMode = false
m.editTitleBuf = ""
m.editTitleCursor = 0
if s == nil || title == "" {
return m, nil
}
cmd := m.setUserTitle(s.ID, title)
m.launched = "rename: " + title
return m, cmd
}
func (m *Model) setUserTitle(sessionID, title string) tea.Cmd {
for _, pd := range m.history.Projects {
for _, s := range pd.Sessions {
if s.ID == sessionID {
s.UserTitle = title
break
}
}
}
if m.history.Cache != nil {
entry, ok := m.history.Cache[sessionID]
if !ok {
// 缓存缺 entry(如缓存文件损坏/新会话未入缓存)时补建,
// 否则标题只改内存不落盘,重启后丢失
entry = &cacheEntry{}
m.history.Cache[sessionID] = entry
}
entry.UserTitle = title
m.cacheDirty = true
}
// 调度落盘,避免改名只存在内存、被磁盘旧缓存覆盖
return cacheFlushTick()
}
func (m *Model) flushCache() { func (m *Model) flushCache() {
if !m.cacheDirty || m.history.Cache == nil { if !m.cacheDirty || m.history.Cache == nil {
return return
+65
View File
@@ -0,0 +1,65 @@
package internal
import (
"testing"
tea "charm.land/bubbletea/v2"
)
// TestEditTitleViaRealKeyPress 用真实 tea.KeyPressMsg 走完整 Update 流程,
// 验证运行时按键是否能真正写入编辑缓冲(用户反馈"界面未能输入名称")。
func TestEditTitleViaRealKeyPress(t *testing.T) {
m := NewModel()
m.history.Projects = []*ProjectDir{
{DirShort: "test", Sessions: []*Session{
{ID: "s1", AISummary: "旧标题"},
}},
}
m.history.DirCursor = 2
m.history.SessCursor = 0
m.history.Loaded = true
send := func(msg tea.Msg) tea.Model {
var cmd tea.Cmd
var model tea.Model = m
model, cmd = m.Update(msg)
_ = cmd
return model
}
// 1. 真实按键 'e' 进入编辑模式(Code+Text 同 ultraviolet 实测构造)
send(tea.KeyPressMsg{Code: 'e', Text: "e"})
if !m.editTitleMode {
t.Fatal("真实 KeyPressMsg(e) 应进入编辑模式")
}
if m.editTitleBuf != "旧标题" {
t.Fatalf("编辑缓冲应预填旧标题, got %q", m.editTitleBuf)
}
// 2. 真实按键 'a'(ASCII 字符)
send(tea.KeyPressMsg{Code: 'a', Text: "a"})
if m.editTitleBuf != "旧标题a" {
t.Fatalf("真实按键 a 应追加, got %q", m.editTitleBuf)
}
// 3. 真实空格键(Code=KeySpace, Text=" ")
send(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
if m.editTitleBuf != "旧标题a " {
t.Fatalf("空格应插入, got %q", m.editTitleBuf)
}
// 4. 真实中文输入(多 rune,Code=KeyExtended)
send(tea.KeyPressMsg{Code: tea.KeyExtended, Text: "新"})
if m.editTitleBuf != "旧标题a 新" {
t.Fatalf("中文应追加, got %q", m.editTitleBuf)
}
// 5. 回车确认
send(tea.KeyPressMsg{Code: tea.KeyEnter, Text: ""})
if m.editTitleMode {
t.Fatal("回车应退出编辑模式")
}
if m.launched != "rename: 旧标题a 新" {
t.Fatalf("确认后 launched 应记录改名, got %q", m.launched)
}
}
+101
View File
@@ -0,0 +1,101 @@
package internal
import "testing"
// TestEditTitleInput 验证编辑标题模式下的按键输入逻辑
func TestEditTitleInput(t *testing.T) {
m := NewModel()
m.history.Projects = []*ProjectDir{
{DirShort: "test", Sessions: []*Session{
{ID: "s1", AISummary: "旧标题"},
}},
}
m.history.DirCursor = 2
m.history.SessCursor = 0
m.history.FocusPanel = 1
m.history.Loaded = true
// 1. 按 e 进入编辑模式
m, _ = m.handleKey("e")
if !m.editTitleMode {
t.Fatal("按 e 应进入编辑模式")
}
if m.editTitleBuf != "旧标题" {
t.Fatalf("编辑缓冲应预填旧标题, got %q", m.editTitleBuf)
}
if m.editTitleCursor != 3 {
t.Fatalf("光标应在末尾, got %d", m.editTitleCursor)
}
// 2. 输入英文 a
m, _ = m.handleKey("a")
if m.editTitleBuf != "旧标题a" {
t.Fatalf("输入 a 应追加, got %q", m.editTitleBuf)
}
if m.editTitleCursor != 4 {
t.Fatalf("输入 a 后光标应在 4, got %d", m.editTitleCursor)
}
// 3. 空格
m, _ = m.handleKey("space")
if m.editTitleBuf != "旧标题a " {
t.Fatalf("空格应插入空格, got %q", m.editTitleBuf)
}
if m.editTitleCursor != 5 {
t.Fatalf("空格后光标应在 5, got %d", m.editTitleCursor)
}
// 4. backspace 删末尾
m, _ = m.handleKey("backspace")
if m.editTitleBuf != "旧标题a" {
t.Fatalf("backspace 应删末尾, got %q", m.editTitleBuf)
}
if m.editTitleCursor != 4 {
t.Fatalf("backspace 后光标应在 4, got %d", m.editTitleCursor)
}
// 5. 输入中文 新
m, _ = m.handleKey("新")
if m.editTitleBuf != "旧标题a新" {
t.Fatalf("输入中文应追加, got %q", m.editTitleBuf)
}
if m.editTitleCursor != 5 {
t.Fatalf("输入中文后光标应在 5, got %d", m.editTitleCursor)
}
// 6. 左移光标
m, _ = m.handleKey("left")
if m.editTitleCursor != 4 {
t.Fatalf("左移后光标应在 4, got %d", m.editTitleCursor)
}
// 7. 在光标处插入
m, _ = m.handleKey("X")
if m.editTitleBuf != "旧标题aX新" {
t.Fatalf("光标处插入 'X' 失败, got %q", m.editTitleBuf)
}
if m.editTitleCursor != 5 {
t.Fatalf("插入后光标应在 5, got %d", m.editTitleCursor)
}
// 8. delete 删除光标处字符
m, _ = m.handleKey("delete")
if m.editTitleBuf != "旧标题aX" {
t.Fatalf("delete 应删光标处字符, got %q", m.editTitleBuf)
}
if m.editTitleCursor != 5 {
t.Fatalf("delete 后光标应在 5, got %d", m.editTitleCursor)
}
// 9. esc 取消
m, _ = m.handleKey("esc")
if m.editTitleMode {
t.Fatal("esc 应退出编辑模式")
}
if m.editTitleBuf != "" {
t.Fatalf("esc 应清空缓冲, got %q", m.editTitleBuf)
}
if m.editTitleCursor != 0 {
t.Fatalf("esc 应重置光标, got %d", m.editTitleCursor)
}
}
+1332 -84
View File
File diff suppressed because it is too large Load Diff
+263 -8
View File
@@ -1,12 +1,15 @@
package internal package internal
import ( import tea "charm.land/bubbletea/v2"
"charm.land/bubbletea/v2"
)
// --- 按键分发 --- // --- 按键分发 ---
func (m *Model) handleKey(s string) (*Model, tea.Cmd) { func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
// 编辑标题时优先走输入处理,避免 q/1/2/u 等被全局拦截
if m.editTitleMode {
return m.handleEditTitleInput(s)
}
switch s { switch s {
case "q", "ctrl+c": case "q", "ctrl+c":
return m, tea.Quit return m, tea.Quit
@@ -17,9 +20,11 @@ func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
} }
} }
// Tab 快捷切换: 1=工作空间 2=历史对话 // Tab 快捷切换: 1=工作空间 2=历史对话 3=技能
if s == "1" { if s == "1" {
if m.IsHistoryTab() { m.editTitleMode = false
m.editTitleBuf = ""
if m.IsHistoryTab() || m.IsSkillsTab() {
m.activeGroup = 0 m.activeGroup = 0
m.resetCursor() m.resetCursor()
m.wsFocus = 1 m.wsFocus = 1
@@ -35,7 +40,19 @@ func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
} }
return m, nil return m, nil
} }
if s == "3" {
if !m.IsSkillsTab() {
m.activeGroup = len(Groups) + 1 // SKILLS
m.resetCursor()
m.wsFocus = 1
return m, m.onSkillsTabSwitch()
}
return m, nil
}
if m.IsSkillsTab() {
return m.handleSkillsKey(s)
}
if m.IsHistoryTab() { if m.IsHistoryTab() {
return m.handleHistoryKey(s) return m.handleHistoryKey(s)
} }
@@ -50,6 +67,140 @@ func (m *Model) onTabSwitch() tea.Cmd {
return ScanSessionsCmd() return ScanSessionsCmd()
} }
func (m *Model) onSkillsTabSwitch() tea.Cmd {
if m.Skills.Loaded {
return nil
}
m.Skills.Scanning = true
return ScanSkillsCmd()
}
// --- 技能 tab 按键 ---
func (m *Model) handleSkillsKey(s string) (*Model, tea.Cmd) {
switch s {
case "up", "k":
switch m.Skills.FocusPanel {
case 0:
m.moveSkillsSkill(-1)
case 1:
m.moveSkillsFile(-1)
case 2:
m.scrollSkillContent(-1)
}
case "down", "j":
switch m.Skills.FocusPanel {
case 0:
m.moveSkillsSkill(1)
case 1:
m.moveSkillsFile(1)
case 2:
m.scrollSkillContent(1)
}
case "pgup":
if m.Skills.FocusPanel == 2 {
m.scrollSkillContent(-10)
}
case "pgdown":
if m.Skills.FocusPanel == 2 {
m.scrollSkillContent(10)
}
case "tab", "right", "l":
// 进内容面板需已加载内容
if m.Skills.FocusPanel < 1 {
m.Skills.FocusPanel = 1
m.autoLoadCurrentFile()
} else if m.Skills.FocusPanel == 1 && m.Skills.ContentPath != "" {
m.Skills.FocusPanel = 2
}
case "shift+tab", "left", "h":
if m.Skills.FocusPanel > 0 {
m.Skills.FocusPanel--
}
case "enter":
switch m.Skills.FocusPanel {
case 0:
m.Skills.FocusPanel = 1
m.Skills.FileCur = 0
m.autoLoadCurrentFile()
case 1:
fn := m.currentFlatNode()
if fn != nil {
if fn.IsDir {
fn.Expanded = !fn.Expanded
flat := m.skillsFlattened()
if m.Skills.FileCur >= len(flat) {
m.Skills.FileCur = len(flat) - 1
}
} else {
m.loadSkillContent(fn.Path)
m.Skills.FocusPanel = 2 // 打开文件即聚焦内容,便于滚动
}
}
}
case "r", "f5":
m.Skills.Loaded = false
m.Skills.Scanning = true
return m, ScanSkillsCmd()
}
return m, nil
}
// scrollSkillContent 滚动内容面板,边界由 View 层按实际高度再夹紧
func (m *Model) scrollSkillContent(dir int) {
m.Skills.ContentScroll += dir
if m.Skills.ContentScroll < 0 {
m.Skills.ContentScroll = 0
}
}
// autoLoadCurrentFile 若当前文件树光标停在文件上,自动加载其内容
func (m *Model) autoLoadCurrentFile() {
if fn := m.currentFlatNode(); fn != nil && !fn.IsDir {
m.loadSkillContent(fn.Path)
}
}
func (m *Model) moveSkillsSkill(dir int) {
if len(m.Skills.Skills) == 0 {
return
}
m.Skills.SkillCur += dir
if m.Skills.SkillCur < 0 {
m.Skills.SkillCur = len(m.Skills.Skills) - 1
}
if m.Skills.SkillCur >= len(m.Skills.Skills) {
m.Skills.SkillCur = 0
}
m.Skills.FileCur = 0
m.Skills.Content = ""
m.Skills.ContentPath = ""
}
func (m *Model) moveSkillsFile(dir int) {
flat := m.skillsFlattened()
if len(flat) == 0 {
return
}
m.Skills.FileCur += dir
if m.Skills.FileCur < 0 {
m.Skills.FileCur = len(flat) - 1
}
if m.Skills.FileCur >= len(flat) {
m.Skills.FileCur = 0
}
// 光标移到文件即自动加载内容;目录则清空内容区
if fn := m.currentFlatNode(); fn != nil {
if fn.IsDir {
m.Skills.Content = ""
m.Skills.ContentPath = ""
m.Skills.ContentScroll = 0
} else {
m.loadSkillContent(fn.Path)
}
}
}
// --- Workspace 按键 --- // --- Workspace 按键 ---
func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) { func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) {
@@ -81,18 +232,23 @@ func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) {
return m.launchByInput() return m.launchByInput()
} }
if m.wsFocus == 0 { if m.wsFocus == 0 {
if m.wsTabCur >= len(Groups) { if m.wsTabCur == len(Groups) {
m.activeGroup = len(Groups) m.activeGroup = len(Groups)
m.resetCursor() m.resetCursor()
return m, m.onTabSwitch() return m, m.onTabSwitch()
} }
if m.wsTabCur == len(Groups)+1 {
m.activeGroup = len(Groups) + 1
m.resetCursor()
return m, m.onSkillsTabSwitch()
}
m.activeGroup = m.wsTabCur m.activeGroup = m.wsTabCur
m.wsFocus = 1 m.wsFocus = 1
} else { } else {
return m.launchSelected() return m.launchSelected()
} }
default: default:
if len(s) == 1 && s[0] >= '0' && s[0] <= '9' { if len(s) == 1 && s[0] >= '0' && s[0] <= '9' && len(m.inputBuf) < 4 {
m.inputBuf += s m.inputBuf += s
} }
} }
@@ -105,7 +261,7 @@ func (m *Model) resetCursor() {
} }
func (m *Model) moveGroupCursor(dir int) { func (m *Model) moveGroupCursor(dir int) {
total := len(Groups) + 1 // 含历史入口 total := len(Groups) + 2 // 含历史对话入口和技能入口
m.wsTabCur = (m.wsTabCur + dir + total) % total m.wsTabCur = (m.wsTabCur + dir + total) % total
if m.wsTabCur < len(Groups) { if m.wsTabCur < len(Groups) {
m.activeGroup = m.wsTabCur m.activeGroup = m.wsTabCur
@@ -119,6 +275,9 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
if m.forkMode { if m.forkMode {
return m.handleForkInput(s) return m.handleForkInput(s)
} }
if m.editTitleMode {
return m.handleEditTitleInput(s)
}
switch s { switch s {
case "up", "k": case "up", "k":
@@ -161,6 +320,14 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
} }
case "f": case "f":
m.toggleFavorite() m.toggleFavorite()
case "e", "E":
if m.currentSession() != nil && !m.forkMode {
s := m.currentSession()
m.editTitleMode = true
m.editTitleBuf = s.DisplayTitle()
m.editTitleCursor = len([]rune(m.editTitleBuf))
m.history.FocusPanel = 1
}
case "c": case "c":
if m.currentSession() != nil && m.history.FocusPanel == 1 { if m.currentSession() != nil && m.history.FocusPanel == 1 {
m.forkMode = true m.forkMode = true
@@ -170,6 +337,92 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
return m, nil return m, nil
} }
func (m *Model) handleEditTitleInput(s string) (*Model, tea.Cmd) {
switch s {
case "enter":
return m.confirmEditTitle()
case "esc":
m.editTitleMode = false
m.editTitleBuf = ""
m.editTitleCursor = 0
case "backspace":
m.backspaceTitle()
case "delete":
m.deleteTitleChar()
case "left", "h":
if m.editTitleCursor > 0 {
m.editTitleCursor--
}
case "right", "l":
r := []rune(m.editTitleBuf)
if m.editTitleCursor < len(r) {
m.editTitleCursor++
}
case "home", "ctrl+a":
m.editTitleCursor = 0
case "end", "ctrl+e":
m.editTitleCursor = len([]rune(m.editTitleBuf))
case "space":
m.insertTitleRune(' ')
default:
switch s {
case "up", "down", "tab", "shift+tab":
default:
if len(s) == 1 && s[0] >= 32 {
m.insertTitleRune(rune(s[0]))
} else if len(s) > 1 {
m.insertTitleString(s)
}
}
}
return m, nil
}
func (m *Model) insertTitleRune(r rune) {
buf := []rune(m.editTitleBuf)
pos := m.editTitleCursor
if pos > len(buf) {
pos = len(buf)
}
buf = append(buf[:pos], append([]rune{r}, buf[pos:]...)...)
m.editTitleBuf = string(buf)
m.editTitleCursor = pos + 1
}
func (m *Model) insertTitleString(s string) {
buf := []rune(m.editTitleBuf)
pos := m.editTitleCursor
if pos > len(buf) {
pos = len(buf)
}
ins := []rune(s)
buf = append(buf[:pos], append(ins, buf[pos:]...)...)
m.editTitleBuf = string(buf)
m.editTitleCursor = pos + len(ins)
}
func (m *Model) backspaceTitle() {
buf := []rune(m.editTitleBuf)
if m.editTitleCursor > 0 && len(buf) > 0 {
pos := m.editTitleCursor - 1
if pos > len(buf) {
pos = len(buf) - 1
}
buf = append(buf[:pos], buf[pos+1:]...)
m.editTitleBuf = string(buf)
m.editTitleCursor--
}
}
func (m *Model) deleteTitleChar() {
buf := []rune(m.editTitleBuf)
pos := m.editTitleCursor
if len(buf) > 0 && pos < len(buf) {
buf = append(buf[:pos], buf[pos+1:]...)
m.editTitleBuf = string(buf)
}
}
func (m *Model) handleForkInput(s string) (*Model, tea.Cmd) { func (m *Model) handleForkInput(s string) (*Model, tea.Cmd) {
switch s { switch s {
case "enter": case "enter":
@@ -184,6 +437,8 @@ func (m *Model) handleForkInput(s string) (*Model, tea.Cmd) {
default: default:
if len(s) == 1 && s[0] >= 32 && s[0] < 127 { if len(s) == 1 && s[0] >= 32 && s[0] < 127 {
m.forkBuf += s m.forkBuf += s
} else if len(s) > 1 {
m.forkBuf += s
} }
} }
return m, nil return m, nil
+88 -28
View File
@@ -3,12 +3,14 @@ package internal
import ( import (
"fmt" "fmt"
"log" "log"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
) )
// SessionAction 描述一个 Claude Code 会话启动动作 // SessionAction 描述一个 Claude Code 会话启动动作
@@ -45,8 +47,9 @@ func (a SessionAction) pwshScript() string {
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"=== %s ===\" -ForegroundColor Cyan", a.Title)) b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"=== %s ===\" -ForegroundColor Cyan", a.Title))
if a.Prompt != "" && a.Name == "" { if a.Prompt != "" && a.Name == "" {
// workspace prompt 显示 // workspace prompt 显示(转义双引号,避免破坏 PowerShell 脚本)
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"Prompt: %s\" -ForegroundColor Yellow", a.Prompt)) pe := strings.ReplaceAll(a.Prompt, "\"", "`\"\"")
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"Prompt: %s\" -ForegroundColor Yellow", pe))
} }
b.WriteString(fmt.Sprintf("\n\t\tcd \"%s\"", toWinPath(a.Cwd))) b.WriteString(fmt.Sprintf("\n\t\tcd \"%s\"", toWinPath(a.Cwd)))
@@ -74,10 +77,14 @@ func (a SessionAction) bashScript() string {
b.WriteString(fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== %s ==="`, a.Title, a.Title)) b.WriteString(fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== %s ==="`, a.Title, a.Title))
if a.Prompt != "" && a.Name == "" { if a.Prompt != "" && a.Name == "" {
b.WriteString(fmt.Sprintf(` && echo "Prompt: %s"`, a.Prompt)) // bash: 转义双引号与 $ 反斜杠,避免命令注入/语法错误
pe := strings.ReplaceAll(a.Prompt, "\\", "\\\\")
pe = strings.ReplaceAll(pe, "\"", "\\\"")
pe = strings.ReplaceAll(pe, "$", "\\$")
b.WriteString(fmt.Sprintf(" && echo \"Prompt: %s\"", pe))
} }
b.WriteString(fmt.Sprintf(` && cd "%s"`, a.Cwd)) b.WriteString(fmt.Sprintf(" && cd \"%s\"", a.Cwd))
b.WriteString(" && claude") b.WriteString(" && claude")
if a.SessionID != "" { if a.SessionID != "" {
@@ -99,42 +106,91 @@ func (a SessionAction) bashScript() string {
// --- 启动辅助 --- // --- 启动辅助 ---
// powershellExe 返回可用的 PowerShell 可执行文件名:优先 pwsh (PS7),找不到则回退 powershell (PS5,Windows 自带)。
// 脚本只用 $Host.UI.RawUI、Write-Host、cd 等兼容语法,PS5 也能跑。
func powershellExe() string {
if _, err := exec.LookPath("pwsh"); err == nil {
return "pwsh"
}
return "powershell"
}
// wtExe 返回 Windows Terminal 可执行文件路径;未安装/找不到时返回空串。
// 查找顺序:PATH 中的 wt.exe → 应用执行别名标准路径
// %LOCALAPPDATA%\Microsoft\WindowsApps\wt.exe(WindowsApps 常不在 PATH)。
// 找不到的常见原因:未安装 Windows Terminal(Win10 需 Store 安装)、
// 或 WindowsApps 执行别名被禁用。调用方据此回退到独立窗口启动。
func wtExe() string {
if p, err := exec.LookPath("wt.exe"); err == nil {
return p
}
if local := os.Getenv("LOCALAPPDATA"); local != "" {
p := filepath.Join(local, "Microsoft", "WindowsApps", "wt.exe")
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
func launchInWtWithTitle(dir, tabTitle, script string) { func launchInWtWithTitle(dir, tabTitle, script string) {
encoded := encodePSCommand(script) encoded := encodePSCommand(script)
color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255)) ps := powershellExe()
args := []string{"-w", "0", "-d", dir, "--tabColor", color}
if tabTitle != "" { // 优先 Windows Terminal:在已有窗口(-w 0)里开新标签页
args = append(args, "--title", tabTitle) if wt := wtExe(); wt != "" {
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, ps, "-NoExit", "-EncodedCommand", encoded)
if err := exec.Command(wt, args...).Start(); err == nil {
return
} else {
log.Printf("[u-tabs] launch fail (wt): %v, fallback to cmd start", err)
}
} }
args = append(args, "pwsh", "-NoExit", "-EncodedCommand", encoded)
cmd := exec.Command("wt.exe", args...) // 回退:未安装 Windows Terminal → cmd start 开独立 PowerShell 窗口
// start 语法:START ["title"] [options] command [args]
// 首个参数被引号包裹时才视为窗口标题,否则视为命令。
// Go 的 exec.Command 只在参数含空格时自动添加引号,
// 所以给 tabTitle 追加空格(如尚不含空格)来确保被引号包裹。
titleArg := tabTitle
// 转义标题中的 cmd 特殊字符,防止命令注入:
// 双引号全部移除;& | < > ^ 前加 ^(顺序:先 ^ 再其余)
titleArg = strings.ReplaceAll(titleArg, `"`, "")
titleArg = strings.ReplaceAll(titleArg, "^", "^^")
titleArg = strings.ReplaceAll(titleArg, "&", "^&")
titleArg = strings.ReplaceAll(titleArg, "|", "^|")
titleArg = strings.ReplaceAll(titleArg, "<", "^<")
titleArg = strings.ReplaceAll(titleArg, ">", "^>")
if titleArg != "" && !strings.ContainsAny(titleArg, " \t") {
titleArg += " " // 迫使 Go 自动加引号,窗口标题尾部空格不影响使用
}
args := []string{"/c", "start", titleArg, ps, "-NoExit", "-EncodedCommand", encoded}
cmd := exec.Command("cmd.exe", args...)
cmd.Dir = dir
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 (cmd): %v", err)
} }
} }
// buildResumeAction 构建恢复会话动作 // buildResumeAction 构建恢复会话动作
func buildResumeAction(s *Session) SessionAction { func buildResumeAction(s *Session) SessionAction {
title := s.CustomTitle
if title == "" {
title = s.ID[:8]
}
return SessionAction{ return SessionAction{
Cwd: s.Cwd, Cwd: s.Cwd,
Title: "resume: " + title, Title: "resume: " + s.DisplayTitle(),
SessionID: s.ID, SessionID: s.ID,
} }
} }
// buildForkAction 构建分叉会话动作 // buildForkAction 构建分叉会话动作
func buildForkAction(s *Session, prompt string) SessionAction { func buildForkAction(s *Session, prompt string) SessionAction {
title := s.CustomTitle
if title == "" {
title = s.ID[:8]
}
return SessionAction{ return SessionAction{
Cwd: s.Cwd, Cwd: s.Cwd,
Title: "fork: " + title, Title: "fork: " + s.DisplayTitle(),
SessionID: s.ID, SessionID: s.ID,
Fork: true, Fork: true,
Prompt: prompt, Prompt: prompt,
@@ -169,7 +225,7 @@ func (m *Model) resumeSelected() (*Model, tea.Cmd) {
} }
action := buildResumeAction(s) action := buildResumeAction(s)
action.Execute(m) action.Execute(m)
m.launched = "resume: " + s.CustomTitle m.launched = "resume: " + s.DisplayTitle()
return m, nil return m, nil
} }
@@ -202,11 +258,15 @@ func (m *Model) executeFork() (*Model, tea.Cmd) {
m.forkBuf = "" m.forkBuf = ""
action.Execute(m) action.Execute(m)
title := s.CustomTitle // 记录待绑定 fork:新会话 ID 由 claude CLI 生成,此刻拿不到,
if title == "" { // 留到下次扫描时按 cwd + 时间窗口匹配
title = s.ID[:8] m.pendingForks = append(m.pendingForks, pendingFork{
} SourceID: s.ID,
m.launched = "fork: " + title Cwd: s.Cwd,
At: time.Now(),
})
m.launched = "fork: " + s.DisplayTitle()
return m, nil return m, nil
} }
+22
View File
@@ -0,0 +1,22 @@
package internal
import (
"strings"
"testing"
)
// TestWtExeNeverReturnsBareName 锁定 wtExe 的契约:要么返回真实路径,要么返回空串,
// 绝不能返回裸 "wt.exe"。
// 裸 "wt.exe" 在未安装 Windows Terminal 的机器上会让 exec.Command 启动报
// `exec: "wt.exe": executable file not found in %PATH%`(v0.2.2 的崩溃根因)。
// 空串触发 launchInWtWithTitle 的 cmd /c start 独立窗口回退。
func TestWtExeNeverReturnsBareName(t *testing.T) {
got := wtExe()
if got == "wt.exe" {
t.Fatalf("wtExe() returned bare %q; must return full path or empty string", got)
}
if got != "" && !strings.HasSuffix(got, "wt.exe") {
t.Fatalf("wtExe() returned unexpected path %q", got)
}
t.Logf("wtExe() = %q (empty ⇒ wt absent → cmd fallback)", got)
}
+75
View File
@@ -0,0 +1,75 @@
package internal
import (
"testing"
tea "charm.land/bubbletea/v2"
)
// TestRenameSurvivesStaleScan 验证改名持久化竞态:
// 改名后内存缓存已设 UserTitle 但尚未落盘(3s 延迟),
// 此时若触发扫描(切 tab/刷新),ScanCompleteMsg 会用磁盘旧缓存
// (不含新标题)覆盖内存,导致改名丢失。
func TestRenameSurvivesStaleScan(t *testing.T) {
m := NewModel()
m.history.Projects = []*ProjectDir{
{DirShort: "test", Sessions: []*Session{
{ID: "s1", AISummary: "原标题", UserTitle: "我的新名"},
}},
}
// 内存缓存:改名已写入但尚未落盘
m.history.Cache = map[string]*cacheEntry{
"s1": {UserTitle: "我的新名"},
}
m.history.Favorites = map[string]bool{} // 跳过 loadFavorites 文件读取
m.cacheDirty = true
// 现在用户改名后 3 秒内按了 "2"/"r" 触发扫描:
// 扫描从磁盘读到旧缓存(不含 UserTitle),返回的 msg 也无 UserTitle
staleProjects := []*ProjectDir{
{DirShort: "test", Sessions: []*Session{
{ID: "s1", AISummary: "原标题"},
}},
}
staleCache := map[string]*cacheEntry{
"s1": {UserTitle: ""},
}
var cmd tea.Cmd
model, _ := m.Update(ScanCompleteMsg{
Projects: staleProjects,
Cache: staleCache,
})
_ = model
_ = cmd
// 改名应存活:内存缓存的 UserTitle 不应被磁盘旧缓存清掉
got := m.history.Cache["s1"].UserTitle
if got != "我的新名" {
t.Fatalf("扫描后改名丢失: cache UserTitle = %q, 期望 \"我的新名\"", got)
}
// session 的 UserTitle 也应保留
s := m.history.Projects[0].Sessions[0]
if s.UserTitle != "我的新名" {
t.Fatalf("扫描后 session UserTitle 丢失: = %q, 期望 \"我的新名\"", s.UserTitle)
}
}
// TestSetUserTitleBackfillCacheEntry 验证缓存缺 entry 时(缓存文件损坏/
// 新会话未入缓存)重命名会补建 entry,保证标题落盘而非只改内存
func TestSetUserTitleBackfillCacheEntry(t *testing.T) {
m := NewModel()
m.history.Cache = map[string]*cacheEntry{}
m.setUserTitle("missing-session", "新标题")
e, ok := m.history.Cache["missing-session"]
if !ok {
t.Fatal("缓存缺 entry 时应补建")
}
if e.UserTitle != "新标题" {
t.Fatalf("补建 entry 应写入标题, got %q", e.UserTitle)
}
if !m.cacheDirty {
t.Fatal("应置 cacheDirty 调度落盘")
}
}
+261
View File
@@ -0,0 +1,261 @@
package internal
import (
"os"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
)
// ScanSkillsCompleteMsg 技能扫描完成消息
type ScanSkillsCompleteMsg struct {
Skills []*SkillInfo
}
// ScanSkillsCmd 扫描 ~/.claude/skills/ 目录
func ScanSkillsCmd() tea.Cmd {
return func() tea.Msg {
skills, err := ScanSkills()
if err != nil {
return ScanSkillsCompleteMsg{}
}
return ScanSkillsCompleteMsg{Skills: skills}
}
}
// SkillInfo 单个技能信息
type SkillInfo struct {
Name string
Dir string
Description string
Root *SkillFileNode
}
// SkillFileNode 技能文件树节点
type SkillFileNode struct {
Name string
Path string
IsDir bool
Expanded bool
Children []*SkillFileNode
}
// SkillsState 技能 tab 视图状态
type SkillsState struct {
Skills []*SkillInfo
SkillCur int // 技能列表光标 (panel 0)
FileCur int // 文件树光标 (panel 1)
FocusPanel int // 0=技能列表 1=文件树 2=内容
Content string
ContentPath string
ContentScroll int // 内容面板纵向滚动偏移(行)
Loaded bool
Scanning bool
}
// flatNode 文件树扁平化节点
type flatNode struct {
*SkillFileNode
Depth int
}
// ScanSkills 扫描 ~/.claude/skills/ 目录
func ScanSkills() ([]*SkillInfo, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
skillsDir := filepath.Join(home, ".claude", "skills")
return scanSkillsDir(skillsDir)
}
func scanSkillsDir(dir string) ([]*SkillInfo, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var skills []*SkillInfo
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasPrefix(name, ".") {
continue
}
skillPath := filepath.Join(dir, name)
info := &SkillInfo{
Name: name,
Dir: skillPath,
}
// 读取 SKILL.md 获取描述
skillMd := filepath.Join(skillPath, "SKILL.md")
if data, err := os.ReadFile(skillMd); err == nil {
info.Description = extractSkillDesc(string(data))
}
// 构建文件树
info.Root = buildFileTree(skillPath)
if info.Root != nil {
info.Root.Expanded = true // 默认展开根节点
}
skills = append(skills, info)
}
sort.Slice(skills, func(i, j int) bool {
return skills[i].Name < skills[j].Name
})
return skills, nil
}
// extractSkillDesc 从 SKILL.md 提取描述文本
func extractSkillDesc(content string) string {
lines := strings.Split(content, "\n")
afterHeading := false
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") {
afterHeading = true
continue
}
// 跳过分隔线、引言、元数据
if strings.HasPrefix(line, "---") || strings.HasPrefix(line, ">") ||
strings.HasPrefix(line, "##") || strings.HasPrefix(line, "|") ||
strings.HasPrefix(line, "```") || strings.HasPrefix(line, "<!--") {
continue
}
if afterHeading && line != "" {
return truncStr(line, 80)
}
}
return ""
}
// buildFileTree 递归构建文件树
func buildFileTree(rootPath string) *SkillFileNode {
info, err := os.Stat(rootPath)
if err != nil {
return nil
}
node := &SkillFileNode{
Name: filepath.Base(rootPath),
Path: rootPath,
IsDir: info.IsDir(),
}
if !info.IsDir() {
return node
}
entries, err := os.ReadDir(rootPath)
if err != nil {
return node
}
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".") {
continue
}
child := buildFileTree(filepath.Join(rootPath, name))
if child != nil {
node.Children = append(node.Children, child)
}
}
// 排序:目录在前,文件在后;同类型按字母序
sort.Slice(node.Children, func(i, j int) bool {
if node.Children[i].IsDir != node.Children[j].IsDir {
return node.Children[i].IsDir
}
return node.Children[i].Name < node.Children[j].Name
})
return node
}
// --- 文件树扁平化 ---
func (m *Model) skillsFlattened() []flatNode {
s := m.currentSkill()
if s == nil || s.Root == nil {
return nil
}
return flattenNode(s.Root, 0)
}
func flattenNode(node *SkillFileNode, depth int) []flatNode {
var result []flatNode
result = append(result, flatNode{SkillFileNode: node, Depth: depth})
if node.IsDir && node.Expanded {
for _, child := range node.Children {
result = append(result, flattenNode(child, depth+1)...)
}
}
return result
}
// --- 查找与导航 ---
func (m *Model) currentSkill() *SkillInfo {
if m.Skills.Skills == nil || m.Skills.SkillCur < 0 || m.Skills.SkillCur >= len(m.Skills.Skills) {
return nil
}
return m.Skills.Skills[m.Skills.SkillCur]
}
func (m *Model) currentFlatNode() *flatNode {
flat := m.skillsFlattened()
if flat == nil || m.Skills.FileCur < 0 || m.Skills.FileCur >= len(flat) {
return nil
}
return &flat[m.Skills.FileCur]
}
// loadSkillContent 加载文件内容到右侧面板
func (m *Model) loadSkillContent(path string) {
data, err := os.ReadFile(path)
if err != nil {
m.Skills.Content = "无法读取文件"
m.Skills.ContentPath = path
return
}
m.Skills.Content = truncateSkillContent(string(data), 100*1024)
m.Skills.ContentPath = path
m.Skills.ContentScroll = 0
}
// truncateSkillContent 限制内容大小避免大文件卡顿;截断点可能落在
// UTF-8 多字节序列中间,逐字节回退到合法边界,避免尾部出现乱码
func truncateSkillContent(content string, limit int) string {
if len(content) <= limit {
return content
}
content = content[:limit]
for len(content) > 0 {
if r, size := utf8.DecodeLastRuneInString(content); r != utf8.RuneError || size > 1 {
break
}
content = content[:len(content)-1]
}
return content + "\n\n... (文件过大,截断显示)"
}
// IsSkillsTab 判断当前是否在技能 tab
func (m *Model) IsSkillsTab() bool {
return m.activeGroup == len(Groups)+1
}
// --- 文件树节点操作 ---
// findNodeByPath 在树中按路径查找节点
func findNodeByPath(root *SkillFileNode, path string) *SkillFileNode {
if root == nil {
return nil
}
if root.Path == path {
return root
}
for _, child := range root.Children {
if found := findNodeByPath(child, path); found != nil {
return found
}
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package internal
import (
"strings"
"testing"
"unicode/utf8"
)
const truncSuffix = "\n\n... (文件过大,截断显示)"
// TestTruncateSkillContentRuneBoundary 验证截断点落在多字节序列中间时
// 逐字节回退到合法 rune 边界,结果不含残缺字节
func TestTruncateSkillContentRuneBoundary(t *testing.T) {
limit := 100 * 1024
// 中文各 3 字节:分别构造截断点落在中文字符第 1、2 字节后的场景
for _, cutInChar := range []int{1, 2} {
prefix := strings.Repeat("a", limit-cutInChar)
content := prefix + "中文内容超出限制"
got := truncateSkillContent(content, limit)
body := strings.TrimSuffix(got, truncSuffix)
if !utf8.ValidString(body) {
t.Fatalf("截断点落在字符第 %d 字节后, 结果应为合法 UTF-8", cutInChar)
}
if !strings.HasPrefix(body, prefix) {
t.Fatalf("截断点落在字符第 %d 字节后, 不应吞掉 ASCII 前缀", cutInChar)
}
if len(body) > limit {
t.Fatalf("截断点落在字符第 %d 字节后, body 长度 %d 超限 %d", cutInChar, len(body), limit)
}
if !strings.HasSuffix(got, truncSuffix) {
t.Fatalf("截断后应带省略提示后缀, got %q", got[len(got)-40:])
}
}
}
// TestTruncateSkillContentEdge 验证边界:内容不超限原样返回,恰在边界截断不误删
func TestTruncateSkillContentEdge(t *testing.T) {
limit := 100 * 1024
short := "短内容"
if got := truncateSkillContent(short, limit); got != short {
t.Fatalf("未超限内容应原样返回, got %q", got)
}
// 恰好 limit 字节后跟多字节字符:截断点在边界上,不应回退删掉合法内容
exact := strings.Repeat("a", limit)
got := truncateSkillContent(exact+"中文", limit)
body := strings.TrimSuffix(got, truncSuffix)
if body != exact {
t.Fatalf("边界截断不应删减合法内容, body 长度 = %d, 期望 %d", len(body), limit)
}
}
+3 -3
View File
@@ -19,12 +19,12 @@ var (
Red = lipgloss.Color("#f7768e") // red Red = lipgloss.Color("#f7768e") // red
// ── component styles ── // ── component styles ──
TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(Accent) TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(Accent)
SubtitleStyle = lipgloss.NewStyle().Foreground(Dim) SubtitleStyle = lipgloss.NewStyle().Foreground(Dim)
TabActiveStyle = lipgloss.NewStyle().Bold(true).Background(BgPanel).Foreground(Bright).Padding(0, 1) TabActiveStyle = lipgloss.NewStyle().Bold(true).Background(BgPanel).Foreground(Bright).Padding(0, 1)
TabInactiveStyle = lipgloss.NewStyle().Foreground(Dim).Padding(0, 1) TabInactiveStyle = lipgloss.NewStyle().Foreground(Dim).Padding(0, 1)
TabSep = lipgloss.NewStyle().Foreground(BgPanel) TabSep = lipgloss.NewStyle().Foreground(BgPanel)
SelStyle = lipgloss.NewStyle().Foreground(BgDark).Background(Purple).Bold(true) SelStyle = lipgloss.NewStyle().Foreground(BgDark).Background(Purple).Bold(true)
NormStyle = lipgloss.NewStyle().Foreground(Fg) NormStyle = lipgloss.NewStyle().Foreground(Fg)
+5 -5
View File
@@ -14,7 +14,7 @@ import (
) )
// Version 当前版本,发布时更新 // Version 当前版本,发布时更新
const Version = "0.2.2" const Version = "0.2.4"
// --- 远程 JSON 结构 --- // --- 远程 JSON 结构 ---
@@ -25,10 +25,10 @@ type platformInfo struct {
} }
type versionInfo struct { type versionInfo struct {
Version string `json:"version"` Version string `json:"version"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
Changelog string `json:"changelog"` Changelog string `json:"changelog"`
Platforms map[string]platformInfo `json:"platforms"` Platforms map[string]platformInfo `json:"platforms"`
} }
// --- bubbletea 消息类型 --- // --- bubbletea 消息类型 ---
+32 -1
View File
@@ -4,15 +4,46 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"math/big" "math/big"
"os"
"path/filepath"
"runtime" "runtime"
"strings" "strings"
"unicode/utf16" "unicode/utf16"
"unicode/utf8" "unicode/utf8"
) )
// atomicWriteFile 原子写文件:先写临时文件再 rename,避免写入中途崩溃导致目标文件损坏
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
tmp.Close()
if err := os.Chmod(tmpName, perm); err != nil {
os.Remove(tmpName)
return err
}
return os.Rename(tmpName, path)
}
// 平台信息(启动时确定) // 平台信息(启动时确定)
var ( var (
isWindows = runtime.GOOS == "windows" isWindows = runtime.GOOS == "windows"
platformKey = runtime.GOOS + "-" + runtime.GOARCH platformKey = runtime.GOOS + "-" + runtime.GOARCH
) )
+29 -4
View File
@@ -4,9 +4,10 @@ import (
"fmt" "fmt"
"strings" "strings"
"charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"u-tabs/internal/style" "u-tabs/internal/style"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
) )
// --- View 入口 --- // --- View 入口 ---
@@ -33,6 +34,8 @@ func (m *Model) render() string {
if m.IsHistoryTab() { if m.IsHistoryTab() {
b.WriteString(m.renderHistory()) b.WriteString(m.renderHistory())
} else if m.IsSkillsTab() {
b.WriteString(m.renderSkills())
} else { } else {
b.WriteString(m.renderWorkspace()) b.WriteString(m.renderWorkspace())
} }
@@ -66,7 +69,7 @@ func (m *Model) render() string {
helpParts := m.renderHelp() helpParts := m.renderHelp()
helpStr := " " + strings.Join(helpParts, " ") helpStr := " " + strings.Join(helpParts, " ")
verStr := style.DimFg.Render("绝尘 v" + Version) verStr := style.DimFg.Render("U-TABS v" + Version)
padW := m.width - lipgloss.Width(helpStr) - lipgloss.Width(verStr) padW := m.width - lipgloss.Width(helpStr) - lipgloss.Width(verStr)
if padW > 0 { if padW > 0 {
b.WriteString(helpStr + strings.Repeat(" ", padW) + verStr) b.WriteString(helpStr + strings.Repeat(" ", padW) + verStr)
@@ -101,16 +104,37 @@ func (m *Model) renderTabBar() string {
b.WriteString(style.TabInactiveStyle.Render(histLabel)) b.WriteString(style.TabInactiveStyle.Render(histLabel))
} }
b.WriteString(sep)
skillsLabel := " 3 技能 "
if m.IsSkillsTab() {
b.WriteString(style.TabActiveAccent.Render(skillsLabel))
} else {
b.WriteString(style.TabInactiveStyle.Render(skillsLabel))
}
return b.String() return b.String()
} }
func (m *Model) renderHelp() []string { func (m *Model) renderHelp() []string {
if m.IsSkillsTab() {
return []string{
m.fmtHelp("↑↓", "sel"),
m.fmtHelp("←→", "panel"),
m.fmtHelp("Enter", "expand/view"),
m.fmtHelp("1", "workspace"),
m.fmtHelp("2", "history"),
m.fmtHelp("3", "skills"),
m.fmtHelp("r/F5", "refresh"),
m.fmtHelp("q", "quit"),
}
}
if m.IsHistoryTab() { if m.IsHistoryTab() {
return []string{ return []string{
m.fmtHelp("↑↓", "sel"), m.fmtHelp("↑↓", "sel"),
m.fmtHelp("←→", "panel"), m.fmtHelp("←→", "panel"),
m.fmtHelp("Enter", "resume"), m.fmtHelp("Enter", "resume"),
m.fmtHelp("f", "star"), m.fmtHelp("f", "star"),
m.fmtHelp("e", "rename"),
m.fmtHelp("n", "new"), m.fmtHelp("n", "new"),
m.fmtHelp("c", "fork"), m.fmtHelp("c", "fork"),
m.fmtHelp("1", "workspace"), m.fmtHelp("1", "workspace"),
@@ -123,13 +147,14 @@ func (m *Model) renderHelp() []string {
m.fmtHelp("↑↓", "sel"), m.fmtHelp("↑↓", "sel"),
m.fmtHelp("Enter", "run"), m.fmtHelp("Enter", "run"),
m.fmtHelp("2", "history"), m.fmtHelp("2", "history"),
m.fmtHelp("3", "skills"),
m.fmtHelp("q", "quit"), m.fmtHelp("q", "quit"),
} }
} }
func (m *Model) fmtHelp(key, desc string) string { func (m *Model) fmtHelp(key, desc string) string {
return style.AccentFg.Render(key) + return style.AccentFg.Render(key) +
style.DimFg.Render(":" + desc) style.DimFg.Render(":"+desc)
} }
func renderNavEntry(lines *[]string, w int, label string, activeFg string, isCur, focused bool) { func renderNavEntry(lines *[]string, w int, label string, activeFg string, isCur, focused bool) {
+55 -1
View File
@@ -198,7 +198,61 @@ func (m *Model) detailColumnLines(w, listH int) []string {
if m.history.Favorites[s.ID] { if m.history.Favorites[s.ID] {
favPrefix = "★ " favPrefix = "★ "
} }
lines = append(lines, style.DetailTitle.Render(fitWidth(favPrefix+s.DisplayTitle(), w))) // 标题行:编辑模式显示输入框,否则显示普通标题
if m.editTitleMode {
prefix := " ✏ "
prefixW := stringWidth(prefix)
contentW := w - prefixW // 内容区可用宽度
text := m.editTitleBuf
textRunes := []rune(text)
cursor := m.editTitleCursor
if cursor > len(textRunes) {
cursor = len(textRunes)
}
// 计算可见窗口 [start, end):文本超宽时以光标为中心滚动,保证光标可见
start, end := 0, len(textRunes)
if stringWidth(text) > contentW && contentW > 0 {
halfW := contentW / 2
start = cursor
acc := 0
for start > 0 && acc < halfW {
start--
acc += runeWidth(textRunes[start])
}
end = start
acc = 0
for end < len(textRunes) {
rw := runeWidth(textRunes[end])
if acc+rw > contentW {
break
}
acc += rw
end++
}
for cursor >= end && end > start {
start++
end++
}
}
// 将 ▏ 光标嵌入到文本中(同一行),不单独占一行
visible := string(textRunes[start:end])
relCursor := cursor - start
var editLine string
vr := []rune(visible)
if relCursor <= 0 {
editLine = prefix + "▏" + visible
} else if relCursor >= len(vr) {
editLine = prefix + visible + "▏"
} else {
editLine = prefix + string(vr[:relCursor]) + "▏" + string(vr[relCursor:])
}
lines = append(lines, style.InputStyle.Render(fitWidth(editLine, w)))
lines = append(lines, style.DimFg.Render(fitWidth(" [Enter]save [Esc]cancel", w)))
} else {
lines = append(lines, style.DetailTitle.Render(fitWidth(favPrefix+s.DisplayTitle(), w)))
}
lines = append(lines, style.SepFg.Render(fitWidth(sepRunes(w), w))) lines = append(lines, style.SepFg.Render(fitWidth(sepRunes(w), w)))
fav := "-" fav := "-"
+178
View File
@@ -0,0 +1,178 @@
package internal
import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
"u-tabs/internal/style"
)
// --- 技能 tab 三栏渲染 ---
func (m *Model) renderSkills() string {
if !m.Skills.Loaded && !m.Skills.Scanning {
return style.ScanningStyle.Render(" press Tab to load skills...")
}
if m.Skills.Scanning {
return style.ScanningStyle.Render(" scanning skills...")
}
if len(m.Skills.Skills) == 0 {
return style.SubtitleStyle.Render(" no skills found in ~/.claude/skills/")
}
avail := m.width - 8
skillW := max(18, avail*20/100)
fileW := max(22, avail*28/100)
contentW := avail - skillW - fileW
listH := max(3, m.height-7)
skillCw := skillW - 2
fileCw := fileW - 2
contentCw := contentW - 2
skillLines := m.skillsListLines(skillCw, listH-1)
fileLines := m.skillsFileLines(fileCw, listH-1)
contentLines := m.skillsContentLines(contentCw, listH-1)
maxRows := min(listH-1, max(max(len(skillLines), len(fileLines)), len(contentLines)))
panelSty := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(style.BgPanel)
skillBorderFg := style.BgPanel
if m.Skills.FocusPanel == 0 {
skillBorderFg = style.Accent
}
fileBorderFg := style.BgPanel
if m.Skills.FocusPanel == 1 {
fileBorderFg = style.Accent
}
skillBox := panelSty.BorderForeground(skillBorderFg).Width(skillW).Render(
style.DetailTitle.Render("skills") + "\n" + padLinesTo(skillLines, skillCw, maxRows))
fileBox := panelSty.BorderForeground(fileBorderFg).Width(fileW).Render(
style.DetailTitle.Render("files") + "\n" + padLinesTo(fileLines, fileCw, maxRows))
contentBox := panelSty.BorderForeground(style.BgPanel).Width(contentW).Render(
style.DetailTitle.Render("content") + "\n" + padLinesTo(contentLines, contentCw, maxRows))
return lipgloss.JoinHorizontal(lipgloss.Top, skillBox, " ", fileBox, " ", contentBox)
}
func (m *Model) skillsListLines(w, listH int) []string {
skills := m.Skills.Skills
if len(skills) == 0 {
return []string{style.DimFg.Render(fitWidth(" empty", w))}
}
// 先为所有技能累积全部行(含描述行),再按行数视口切片,
// 避免按条目数计算视口导致变长行撑爆面板
type skillBlock struct {
lines []string
}
var blocks []skillBlock
totalLines := 0
curLineOffset := 0
for i, sk := range skills {
cur := " "
if i == m.Skills.SkillCur {
cur = "▸"
}
line := fmt.Sprintf(" %s %s", cur, sk.Name)
sty := style.NormStyle
if i == m.Skills.SkillCur && m.Skills.FocusPanel == 0 {
sty = style.SelStyle
}
blockLines := []string{sty.Render(padRightByWidth(truncateByWidth(line, w), w))}
// 如果有描述,追加一行
if sk.Description != "" {
descLine := " " + sk.Description
descSty := style.DimFg
if i == m.Skills.SkillCur && m.Skills.FocusPanel == 0 {
descSty = style.SelStyle
}
blockLines = append(blockLines, descSty.Render(padRightByWidth(truncateByWidth(descLine, w), w)))
}
if i == m.Skills.SkillCur {
curLineOffset = totalLines
}
totalLines += len(blockLines)
blocks = append(blocks, skillBlock{lines: blockLines})
}
start, end := viewport(curLineOffset, totalLines, listH)
var allLines []string
for _, b := range blocks {
allLines = append(allLines, b.lines...)
}
if end > len(allLines) {
end = len(allLines)
}
return allLines[start:end]
}
func (m *Model) skillsFileLines(w, listH int) []string {
flat := m.skillsFlattened()
if len(flat) == 0 {
return []string{style.DimFg.Render(fitWidth(" ← select skill", w))}
}
start, end := viewport(m.Skills.FileCur, len(flat), listH)
var lines []string
for i := start; i < end; i++ {
fn := flat[i]
indent := strings.Repeat(" ", fn.Depth)
cur := " "
if i == m.Skills.FileCur {
cur = "▸"
}
prefix := " "
if fn.IsDir {
if fn.Expanded {
prefix = "▾ "
} else {
prefix = "▸ "
}
}
display := indent + cur + prefix + fn.Name
sty := style.NormStyle
if fn.IsDir {
sty = style.AccentFg
}
if i == m.Skills.FileCur && m.Skills.FocusPanel == 1 {
sty = style.SelStyle
}
lines = append(lines, sty.Render(padRightByWidth(truncateByWidth(display, w), w)))
}
return lines
}
func (m *Model) skillsContentLines(w, listH int) []string {
if m.Skills.ContentPath == "" {
return []string{style.DimFg.Render(fitWidth(" ← select file to view", w))}
}
header := style.DetailTitle.Render(fitWidth(" "+m.Skills.ContentPath, w))
sep := style.SepFg.Render(fitWidth(sepRunes(w), w))
var lines []string
lines = append(lines, header)
lines = append(lines, sep)
contentLines := strings.Split(m.Skills.Content, "\n")
// 内容面板保留标题和分隔线,其余行按滚动偏移显示
maxLines := max(0, listH-2)
start := m.Skills.ContentScroll
if start > len(contentLines) {
start = len(contentLines)
}
end := min(len(contentLines), start+maxLines)
for _, line := range contentLines[start:end] {
lines = append(lines, style.NormStyle.Render(fitWidth(line, w)))
}
// 滚动到末尾时自动回收偏移,避免窗口缩小时空白
if maxLines > 0 && m.Skills.ContentScroll > max(0, len(contentLines)-maxLines) {
m.Skills.ContentScroll = max(0, len(contentLines)-maxLines)
}
return lines
}
+32
View File
@@ -0,0 +1,32 @@
package internal
import (
"strings"
"testing"
)
// TestSkillsListLinesViewportByLines 验证技能列表视口按行数(含描述行)收口,
// 返回行数不超过 listH,避免变长行撑爆面板
func TestSkillsListLinesViewportByLines(t *testing.T) {
m := NewModel()
m.Skills.Loaded = true
for i := 0; i < 3; i++ {
m.Skills.Skills = append(m.Skills.Skills, &SkillInfo{
Name: "skill-" + strings.Repeat("x", i),
Description: "描述行 " + string(rune('A'+i)),
})
}
m.Skills.SkillCur = 0
m.Skills.FocusPanel = 0
listH := 3 // 足够只显示部分(总共 6 行:3 技能 × 2 行)
lines := m.skillsListLines(30, listH)
if len(lines) > listH {
t.Fatalf("skillsListLines 应返回 ≤ %d 行, got %d", listH, len(lines))
}
// 光标在首个技能时,视口应从头开始且光标行可见
if !strings.Contains(lines[0], "▸") {
t.Fatalf("首行应含光标标记, got %q", lines[0])
}
}
+5 -1
View File
@@ -56,7 +56,7 @@ func (m *Model) renderWorkspace() string {
func (m *Model) tabsColumnLines(w, listH int) []string { func (m *Model) tabsColumnLines(w, listH int) []string {
var lines []string var lines []string
total := len(Groups) + 1 total := len(Groups) + 2 // groups + 历史对话 + 技能
start, end := viewport(m.wsTabCur, total, listH) start, end := viewport(m.wsTabCur, total, listH)
focused := m.wsFocus == 0 focused := m.wsFocus == 0
@@ -66,6 +66,10 @@ func (m *Model) tabsColumnLines(w, listH int) []string {
renderNavEntry(&lines, w, "历史对话", "#7dcfff", m.wsTabCur == i, focused) renderNavEntry(&lines, w, "历史对话", "#7dcfff", m.wsTabCur == i, focused)
continue continue
} }
if i == len(Groups)+1 {
renderNavEntry(&lines, w, "技能", "#7aa2f7", m.wsTabCur == i, focused)
continue
}
g := Groups[i] g := Groups[i]
label := g.Label label := g.Label
+16
View File
@@ -11,6 +11,22 @@ import (
) )
func main() { func main() {
// --fix-titles: 清理缓存中的低质量 AI 摘要(Claude 困惑回复/噪音/过短),
// 下次启动会重新扫描并重新生成。一次性数据维护命令,不进入 TUI。
if len(os.Args) > 1 && os.Args[1] == "--fix-titles" {
n := internal.CleanupBadTitles()
fmt.Printf("已清理 %d 个低质量摘要,重启 u-tabs 后将自动重新生成\n", n)
return
}
// --regen-summaries: 立即并发生成所有缺失的 AI 摘要(AISummary 为空的会话)。
// 通常在 --fix-titles 之后运行,把清理掉的标题重新解析回来。不进入 TUI。
if len(os.Args) > 1 && os.Args[1] == "--regen-summaries" {
ok, fail := internal.RegenerateSummaries(4)
fmt.Printf("摘要重新生成完成: 成功 %d, 降级 %d\n", ok, fail)
return
}
internal.InitData() internal.InitData()
m := internal.NewModel() m := internal.NewModel()
p := tea.NewProgram(m, tea.WithFPS(0)) p := tea.NewProgram(m, tea.WithFPS(0))