diff --git a/internal/app.go b/internal/app.go index e8c7723..30ba2eb 100644 --- a/internal/app.go +++ b/internal/app.go @@ -11,22 +11,24 @@ import ( // Model 主模型 type Model struct { - activeGroup int - cursor int - selected map[int]bool - inputBuf string - width int - height int - launched string - history HistoryState - pendingCmd string - update UpdateState - wsFocus int // workspace 焦点列: 0=tabs 1=list - wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口) - forkMode bool // 分叉输入模式 - forkBuf string // 分叉方向提示输入 - editTitleMode bool // 编辑标题模式 - editTitleBuf string // 标题编辑缓冲区 + activeGroup int + cursor int + selected map[int]bool + inputBuf string + width int + height int + launched string + history HistoryState + pendingCmd string + update UpdateState + wsFocus int // workspace 焦点列: 0=tabs 1=list + wsTabCur int // tabs 列光标 (0..len(Groups), 最后一个是历史入口) + Skills SkillsState + forkMode bool // 分叉输入模式 + forkBuf string // 分叉方向提示输入 + editTitleMode bool // 编辑标题模式 + editTitleBuf string // 标题编辑缓冲区 + editTitleCursor int // 标题光标位置(rune 索引) // 性能缓存 allSessionsCache []*Session // "全部" tab 会话切片缓存 @@ -60,8 +62,29 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.width = msg.Width m.height = msg.Height 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.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.Scanning = false m.allSessionsDirty = true @@ -93,6 +116,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.launched = fmt.Sprintf("refreshed: %d projects, %d sessions", len(msg.Projects), total) // 无论本次是否有文件更新,都尝试补齐缺失的摘要 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 case SummaryResultMsg: m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending) m.cacheDirty = true @@ -139,6 +172,7 @@ func (m *Model) confirmEditTitle() (*Model, tea.Cmd) { title := strings.TrimSpace(m.editTitleBuf) m.editTitleMode = false m.editTitleBuf = "" + m.editTitleCursor = 0 if s == nil || title == "" { return m, nil } diff --git a/internal/edit_title_integration_test.go b/internal/edit_title_integration_test.go new file mode 100644 index 0000000..c092099 --- /dev/null +++ b/internal/edit_title_integration_test.go @@ -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) + } +} diff --git a/internal/edit_title_test.go b/internal/edit_title_test.go new file mode 100644 index 0000000..19aa043 --- /dev/null +++ b/internal/edit_title_test.go @@ -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) + } +} \ No newline at end of file diff --git a/internal/history.go b/internal/history.go index 3e633cc..a695cc0 100644 --- a/internal/history.go +++ b/internal/history.go @@ -1011,7 +1011,10 @@ func generateAISummary(filePath string) (string, []string, []string) { 要求:summary 30字以内中文标题,突出具体内容避免泛化;completed 已完成工作(每项≤30字);pending 未完成/待办(每项≤30字)。只输出JSON。` + "\n\n" + messages - cmd := exec.CommandContext(ctx, "claude", "-p", prompt) + // 用 stdin 传 prompt:Windows 上 claude -p 把命令行参数在首个换行处截断, + // 导致只收到"分析以下Claude Code对话"前缀、消息内容丢失(历史无意义标题的根因)。 + cmd := exec.CommandContext(ctx, "claude", "-p", "--input-format", "text") + cmd.Stdin = strings.NewReader(prompt) output, err := cmd.Output() diff --git a/internal/key.go b/internal/key.go index 21f658e..348d651 100644 --- a/internal/key.go +++ b/internal/key.go @@ -7,7 +7,7 @@ import tea "charm.land/bubbletea/v2" func (m *Model) handleKey(s string) (*Model, tea.Cmd) { // 编辑标题时优先走输入处理,避免 q/1/2/u 等被全局拦截 if m.editTitleMode { - return m.handleHistoryKey(s) + return m.handleEditTitleInput(s) } switch s { @@ -20,11 +20,11 @@ func (m *Model) handleKey(s string) (*Model, tea.Cmd) { } } - // Tab 快捷切换: 1=工作空间 2=历史对话 + // Tab 快捷切换: 1=工作空间 2=历史对话 3=技能 if s == "1" { m.editTitleMode = false m.editTitleBuf = "" - if m.IsHistoryTab() { + if m.IsHistoryTab() || m.IsSkillsTab() { m.activeGroup = 0 m.resetCursor() m.wsFocus = 1 @@ -40,7 +40,19 @@ func (m *Model) handleKey(s string) (*Model, tea.Cmd) { } 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() { return m.handleHistoryKey(s) } @@ -55,6 +67,140 @@ func (m *Model) onTabSwitch() tea.Cmd { 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 按键 --- func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) { @@ -86,11 +232,16 @@ func (m *Model) handleWorkspaceKey(s string) (*Model, tea.Cmd) { return m.launchByInput() } if m.wsFocus == 0 { - if m.wsTabCur >= len(Groups) { + if m.wsTabCur == len(Groups) { m.activeGroup = len(Groups) m.resetCursor() 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.wsFocus = 1 } else { @@ -110,7 +261,7 @@ func (m *Model) resetCursor() { } func (m *Model) moveGroupCursor(dir int) { - total := len(Groups) + 1 // 含历史入口 + total := len(Groups) + 2 // 含历史对话入口和技能入口 m.wsTabCur = (m.wsTabCur + dir + total) % total if m.wsTabCur < len(Groups) { m.activeGroup = m.wsTabCur @@ -174,7 +325,8 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) { s := m.currentSession() m.editTitleMode = true m.editTitleBuf = s.DisplayTitle() - m.history.FocusPanel = 1 // 确保右栏可见 + m.editTitleCursor = len([]rune(m.editTitleBuf)) + m.history.FocusPanel = 1 } case "c": if m.currentSession() != nil && m.history.FocusPanel == 1 { @@ -192,25 +344,85 @@ func (m *Model) handleEditTitleInput(s string) (*Model, tea.Cmd) { case "esc": m.editTitleMode = false m.editTitleBuf = "" + m.editTitleCursor = 0 case "backspace": - r := []rune(m.editTitleBuf) - if len(r) > 0 { - m.editTitleBuf = string(r[:len(r)-1]) + 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: - // 编辑状态下忽略导航/功能键,避免 up/down/tab 等被当作字符输入 switch s { - case "up", "down", "left", "right", "tab", "shift+tab": - // 不处理 + case "up", "down", "tab", "shift+tab": default: - if s != "" && s[0] >= 32 { - m.editTitleBuf += s + 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) { switch s { case "enter": @@ -225,6 +437,8 @@ func (m *Model) handleForkInput(s string) (*Model, tea.Cmd) { default: if len(s) == 1 && s[0] >= 32 && s[0] < 127 { m.forkBuf += s + } else if len(s) > 1 { + m.forkBuf += s } } return m, nil diff --git a/internal/rename_persist_test.go b/internal/rename_persist_test.go new file mode 100644 index 0000000..6d95b0b --- /dev/null +++ b/internal/rename_persist_test.go @@ -0,0 +1,55 @@ +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) + } +} diff --git a/internal/skills.go b/internal/skills.go new file mode 100644 index 0000000..125007e --- /dev/null +++ b/internal/skills.go @@ -0,0 +1,249 @@ +package internal + +import ( + "os" + "path/filepath" + "sort" + "strings" + + 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, "