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