新增: skills 视图与标题编辑测试(未落库功能批量入库)

This commit is contained in:
lxy
2026-08-23 18:25:02 +08:00
parent 95f349bcd7
commit d54d0c4c63
12 changed files with 985 additions and 37 deletions
+50 -16
View File
@@ -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
}
+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)
}
}
+4 -1
View File
@@ -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()
+228 -14
View File
@@ -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
+55
View File
@@ -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)
}
}
+249
View File
@@ -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, "<!--") {
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
}
content := string(data)
// 限制内容大小,避免大文件卡顿
if len(content) > 100*1024 {
content = content[:100*1024] + "\n\n... (文件过大,截断显示)"
}
m.Skills.Content = content
m.Skills.ContentPath = path
m.Skills.ContentScroll = 0
}
// 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
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
)
// Version 当前版本,发布时更新
const Version = "0.2.3"
const Version = "0.2.4"
// --- 远程 JSON 结构 ---
+23
View File
@@ -34,6 +34,8 @@ func (m *Model) render() string {
if m.IsHistoryTab() {
b.WriteString(m.renderHistory())
} else if m.IsSkillsTab() {
b.WriteString(m.renderSkills())
} else {
b.WriteString(m.renderWorkspace())
}
@@ -102,10 +104,30 @@ func (m *Model) renderTabBar() string {
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()
}
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() {
return []string{
m.fmtHelp("↑↓", "sel"),
@@ -125,6 +147,7 @@ func (m *Model) renderHelp() []string {
m.fmtHelp("↑↓", "sel"),
m.fmtHelp("Enter", "run"),
m.fmtHelp("2", "history"),
m.fmtHelp("3", "skills"),
m.fmtHelp("q", "quit"),
}
}
+47 -4
View File
@@ -200,10 +200,53 @@ func (m *Model) detailColumnLines(w, listH int) []string {
}
// 标题行:编辑模式显示输入框,否则显示普通标题
if m.editTitleMode {
editLine := " ✏ " + m.editTitleBuf
editW := w - 4
if lipgloss.Width(editLine) > editW {
editLine = truncateByWidth(editLine, editW-2) + "…"
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)))
+157
View File
@@ -0,0 +1,157 @@
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))}
}
start, end := viewport(m.Skills.SkillCur, len(skills), listH)
var lines []string
for i := start; i < end; i++ {
sk := skills[i]
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
}
lines = append(lines, 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
}
lines = append(lines, descSty.Render(padRightByWidth(truncateByWidth(descLine, w), w)))
}
}
return lines
}
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
}
+5 -1
View File
@@ -56,7 +56,7 @@ func (m *Model) renderWorkspace() string {
func (m *Model) tabsColumnLines(w, listH int) []string {
var lines []string
total := len(Groups) + 1
total := len(Groups) + 2 // groups + 历史对话 + 技能
start, end := viewport(m.wsTabCur, total, listH)
focused := m.wsFocus == 0
@@ -66,6 +66,10 @@ func (m *Model) tabsColumnLines(w, listH int) []string {
renderNavEntry(&lines, w, "历史对话", "#7dcfff", m.wsTabCur == i, focused)
continue
}
if i == len(Groups)+1 {
renderNavEntry(&lines, w, "技能", "#7aa2f7", m.wsTabCur == i, focused)
continue
}
g := Groups[i]
label := g.Label