新增: 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 主模型 // 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 // 分叉输入模式
editTitleMode bool // 编辑标题模式 forkBuf string // 分叉方向提示输入
editTitleBuf string // 标题编辑缓冲区 editTitleMode bool // 编辑标题模式
editTitleBuf string // 标题编辑缓冲区
editTitleCursor int // 标题光标位置(rune 索引)
// 性能缓存 // 性能缓存
allSessionsCache []*Session // "全部" tab 会话切片缓存 allSessionsCache []*Session // "全部" tab 会话切片缓存
@@ -60,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
@@ -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) m.launched = fmt.Sprintf("refreshed: %d projects, %d sessions", len(msg.Projects), total)
// 无论本次是否有文件更新,都尝试补齐缺失的摘要 // 无论本次是否有文件更新,都尝试补齐缺失的摘要
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
case SummaryResultMsg: case SummaryResultMsg:
m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending) m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending)
m.cacheDirty = true m.cacheDirty = true
@@ -139,6 +172,7 @@ func (m *Model) confirmEditTitle() (*Model, tea.Cmd) {
title := strings.TrimSpace(m.editTitleBuf) title := strings.TrimSpace(m.editTitleBuf)
m.editTitleMode = false m.editTitleMode = false
m.editTitleBuf = "" m.editTitleBuf = ""
m.editTitleCursor = 0
if s == nil || title == "" { if s == nil || title == "" {
return m, nil 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 要求: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() 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) { func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
// 编辑标题时优先走输入处理,避免 q/1/2/u 等被全局拦截 // 编辑标题时优先走输入处理,避免 q/1/2/u 等被全局拦截
if m.editTitleMode { if m.editTitleMode {
return m.handleHistoryKey(s) return m.handleEditTitleInput(s)
} }
switch 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" { if s == "1" {
m.editTitleMode = false m.editTitleMode = false
m.editTitleBuf = "" m.editTitleBuf = ""
if m.IsHistoryTab() { if m.IsHistoryTab() || m.IsSkillsTab() {
m.activeGroup = 0 m.activeGroup = 0
m.resetCursor() m.resetCursor()
m.wsFocus = 1 m.wsFocus = 1
@@ -40,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)
} }
@@ -55,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) {
@@ -86,11 +232,16 @@ 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 {
@@ -110,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
@@ -174,7 +325,8 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
s := m.currentSession() s := m.currentSession()
m.editTitleMode = true m.editTitleMode = true
m.editTitleBuf = s.DisplayTitle() m.editTitleBuf = s.DisplayTitle()
m.history.FocusPanel = 1 // 确保右栏可见 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 {
@@ -192,25 +344,85 @@ func (m *Model) handleEditTitleInput(s string) (*Model, tea.Cmd) {
case "esc": case "esc":
m.editTitleMode = false m.editTitleMode = false
m.editTitleBuf = "" m.editTitleBuf = ""
m.editTitleCursor = 0
case "backspace": case "backspace":
r := []rune(m.editTitleBuf) m.backspaceTitle()
if len(r) > 0 { case "delete":
m.editTitleBuf = string(r[:len(r)-1]) 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: default:
// 编辑状态下忽略导航/功能键,避免 up/down/tab 等被当作字符输入
switch s { switch s {
case "up", "down", "left", "right", "tab", "shift+tab": case "up", "down", "tab", "shift+tab":
// 不处理
default: default:
if s != "" && s[0] >= 32 { if len(s) == 1 && s[0] >= 32 {
m.editTitleBuf += s m.insertTitleRune(rune(s[0]))
} else if len(s) > 1 {
m.insertTitleString(s)
} }
} }
} }
return m, nil 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":
@@ -225,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
+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 当前版本,发布时更新 // Version 当前版本,发布时更新
const Version = "0.2.3" const Version = "0.2.4"
// --- 远程 JSON 结构 --- // --- 远程 JSON 结构 ---
+23
View File
@@ -34,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())
} }
@@ -102,10 +104,30 @@ 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"),
@@ -125,6 +147,7 @@ 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"),
} }
} }
+47 -4
View File
@@ -200,10 +200,53 @@ func (m *Model) detailColumnLines(w, listH int) []string {
} }
// 标题行:编辑模式显示输入框,否则显示普通标题 // 标题行:编辑模式显示输入框,否则显示普通标题
if m.editTitleMode { if m.editTitleMode {
editLine := " ✏ " + m.editTitleBuf prefix := " ✏ "
editW := w - 4 prefixW := stringWidth(prefix)
if lipgloss.Width(editLine) > editW { contentW := w - prefixW // 内容区可用宽度
editLine = truncateByWidth(editLine, editW-2) + "…" 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.InputStyle.Render(fitWidth(editLine, w)))
lines = append(lines, style.DimFg.Render(fitWidth(" [Enter]save [Esc]cancel", 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 { 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