新增: skills 视图与标题编辑测试(未落库功能批量入库)
This commit is contained in:
+50
-16
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -14,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Version 当前版本,发布时更新
|
// Version 当前版本,发布时更新
|
||||||
const Version = "0.2.3"
|
const Version = "0.2.4"
|
||||||
|
|
||||||
// --- 远程 JSON 结构 ---
|
// --- 远程 JSON 结构 ---
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)))
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user