Compare commits
7
Commits
v0.2.2
..
cc58de4661
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc58de4661 | ||
|
|
558b676eea | ||
|
|
d54d0c4c63 | ||
|
|
95f349bcd7 | ||
|
|
043a7d9227 | ||
|
|
ec2603444d | ||
|
|
45e32193af |
@@ -5,6 +5,9 @@ u-tabs-linux
|
||||
whale-windows-amd64.exe
|
||||
dist/
|
||||
|
||||
# Vendor (依赖由 go.mod 管理,不纳入版本控制)
|
||||
vendor/
|
||||
|
||||
# Config
|
||||
config.yaml
|
||||
|
||||
|
||||
+103
-22
@@ -3,33 +3,39 @@ package internal
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbletea/v2"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// 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 // 分叉方向提示输入
|
||||
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 会话切片缓存
|
||||
allSessionsDirty bool // 缓存是否需要重建
|
||||
cacheDirty bool // session-cache.json 是否需要写盘
|
||||
summaryCount int // 本次会话已生成的摘要数
|
||||
allSessionsCache []*Session // "全部" tab 会话切片缓存
|
||||
allSessionsDirty bool // 缓存是否需要重建
|
||||
cacheDirty bool // session-cache.json 是否需要写盘
|
||||
summaryCount int // 本次会话已生成的摘要数
|
||||
pendingForks []pendingFork // executeFork 留下的待绑定项,扫描后解析
|
||||
}
|
||||
|
||||
func NewModel() *Model {
|
||||
@@ -56,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
|
||||
@@ -76,14 +103,28 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 处理 executeFork 留下的待绑定 fork
|
||||
if len(m.pendingForks) > 0 {
|
||||
resolvePendingForks(m.pendingForks, m.history.Projects)
|
||||
m.pendingForks = nil
|
||||
m.cacheDirty = true
|
||||
}
|
||||
total := 0
|
||||
for _, pd := range msg.Projects {
|
||||
total += len(pd.Sessions)
|
||||
}
|
||||
m.launched = fmt.Sprintf("refreshed: %d projects, %d sessions", len(msg.Projects), total)
|
||||
if len(msg.UpdatedIDs) > 0 {
|
||||
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:
|
||||
m.applySummary(msg.SessionID, msg.Summary, msg.Completed, msg.Pending)
|
||||
@@ -114,6 +155,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
case tea.QuitMsg:
|
||||
// 最终落盘:扫描未完成时 Cache 可能为 nil,此时无数据可写,
|
||||
// 已生成的摘要会在下次启动扫描时由 cache 维护
|
||||
m.flushCache()
|
||||
return m, tea.Quit
|
||||
}
|
||||
@@ -124,6 +167,44 @@ func (m *Model) GetPendingCmd() string {
|
||||
return m.pendingCmd
|
||||
}
|
||||
|
||||
func (m *Model) confirmEditTitle() (*Model, tea.Cmd) {
|
||||
s := m.currentSession()
|
||||
title := strings.TrimSpace(m.editTitleBuf)
|
||||
m.editTitleMode = false
|
||||
m.editTitleBuf = ""
|
||||
m.editTitleCursor = 0
|
||||
if s == nil || title == "" {
|
||||
return m, nil
|
||||
}
|
||||
cmd := m.setUserTitle(s.ID, title)
|
||||
m.launched = "rename: " + title
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) setUserTitle(sessionID, title string) tea.Cmd {
|
||||
for _, pd := range m.history.Projects {
|
||||
for _, s := range pd.Sessions {
|
||||
if s.ID == sessionID {
|
||||
s.UserTitle = title
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.history.Cache != nil {
|
||||
entry, ok := m.history.Cache[sessionID]
|
||||
if !ok {
|
||||
// 缓存缺 entry(如缓存文件损坏/新会话未入缓存)时补建,
|
||||
// 否则标题只改内存不落盘,重启后丢失
|
||||
entry = &cacheEntry{}
|
||||
m.history.Cache[sessionID] = entry
|
||||
}
|
||||
entry.UserTitle = title
|
||||
m.cacheDirty = true
|
||||
}
|
||||
// 调度落盘,避免改名只存在内存、被磁盘旧缓存覆盖
|
||||
return cacheFlushTick()
|
||||
}
|
||||
|
||||
func (m *Model) flushCache() {
|
||||
if !m.cacheDirty || m.history.Cache == nil {
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+1332
-84
File diff suppressed because it is too large
Load Diff
+263
-8
@@ -1,12 +1,15 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"charm.land/bubbletea/v2"
|
||||
)
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
// --- 按键分发 ---
|
||||
|
||||
func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
|
||||
// 编辑标题时优先走输入处理,避免 q/1/2/u 等被全局拦截
|
||||
if m.editTitleMode {
|
||||
return m.handleEditTitleInput(s)
|
||||
}
|
||||
|
||||
switch s {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
@@ -17,9 +20,11 @@ func (m *Model) handleKey(s string) (*Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
// Tab 快捷切换: 1=工作空间 2=历史对话
|
||||
// Tab 快捷切换: 1=工作空间 2=历史对话 3=技能
|
||||
if s == "1" {
|
||||
if m.IsHistoryTab() {
|
||||
m.editTitleMode = false
|
||||
m.editTitleBuf = ""
|
||||
if m.IsHistoryTab() || m.IsSkillsTab() {
|
||||
m.activeGroup = 0
|
||||
m.resetCursor()
|
||||
m.wsFocus = 1
|
||||
@@ -35,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)
|
||||
}
|
||||
@@ -50,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) {
|
||||
@@ -81,18 +232,23 @@ 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 {
|
||||
return m.launchSelected()
|
||||
}
|
||||
default:
|
||||
if len(s) == 1 && s[0] >= '0' && s[0] <= '9' {
|
||||
if len(s) == 1 && s[0] >= '0' && s[0] <= '9' && len(m.inputBuf) < 4 {
|
||||
m.inputBuf += s
|
||||
}
|
||||
}
|
||||
@@ -105,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
|
||||
@@ -119,6 +275,9 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
|
||||
if m.forkMode {
|
||||
return m.handleForkInput(s)
|
||||
}
|
||||
if m.editTitleMode {
|
||||
return m.handleEditTitleInput(s)
|
||||
}
|
||||
|
||||
switch s {
|
||||
case "up", "k":
|
||||
@@ -161,6 +320,14 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
|
||||
}
|
||||
case "f":
|
||||
m.toggleFavorite()
|
||||
case "e", "E":
|
||||
if m.currentSession() != nil && !m.forkMode {
|
||||
s := m.currentSession()
|
||||
m.editTitleMode = true
|
||||
m.editTitleBuf = s.DisplayTitle()
|
||||
m.editTitleCursor = len([]rune(m.editTitleBuf))
|
||||
m.history.FocusPanel = 1
|
||||
}
|
||||
case "c":
|
||||
if m.currentSession() != nil && m.history.FocusPanel == 1 {
|
||||
m.forkMode = true
|
||||
@@ -170,6 +337,92 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) handleEditTitleInput(s string) (*Model, tea.Cmd) {
|
||||
switch s {
|
||||
case "enter":
|
||||
return m.confirmEditTitle()
|
||||
case "esc":
|
||||
m.editTitleMode = false
|
||||
m.editTitleBuf = ""
|
||||
m.editTitleCursor = 0
|
||||
case "backspace":
|
||||
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:
|
||||
switch s {
|
||||
case "up", "down", "tab", "shift+tab":
|
||||
default:
|
||||
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":
|
||||
@@ -184,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
|
||||
|
||||
+88
-28
@@ -3,12 +3,14 @@ package internal
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbletea/v2"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// SessionAction 描述一个 Claude Code 会话启动动作
|
||||
@@ -45,8 +47,9 @@ func (a SessionAction) pwshScript() string {
|
||||
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"=== %s ===\" -ForegroundColor Cyan", a.Title))
|
||||
|
||||
if a.Prompt != "" && a.Name == "" {
|
||||
// workspace prompt 显示
|
||||
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"Prompt: %s\" -ForegroundColor Yellow", a.Prompt))
|
||||
// workspace prompt 显示(转义双引号,避免破坏 PowerShell 脚本)
|
||||
pe := strings.ReplaceAll(a.Prompt, "\"", "`\"\"")
|
||||
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"Prompt: %s\" -ForegroundColor Yellow", pe))
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("\n\t\tcd \"%s\"", toWinPath(a.Cwd)))
|
||||
@@ -74,10 +77,14 @@ func (a SessionAction) bashScript() string {
|
||||
b.WriteString(fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== %s ==="`, a.Title, a.Title))
|
||||
|
||||
if a.Prompt != "" && a.Name == "" {
|
||||
b.WriteString(fmt.Sprintf(` && echo "Prompt: %s"`, a.Prompt))
|
||||
// bash: 转义双引号与 $ 反斜杠,避免命令注入/语法错误
|
||||
pe := strings.ReplaceAll(a.Prompt, "\\", "\\\\")
|
||||
pe = strings.ReplaceAll(pe, "\"", "\\\"")
|
||||
pe = strings.ReplaceAll(pe, "$", "\\$")
|
||||
b.WriteString(fmt.Sprintf(" && echo \"Prompt: %s\"", pe))
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf(` && cd "%s"`, a.Cwd))
|
||||
b.WriteString(fmt.Sprintf(" && cd \"%s\"", a.Cwd))
|
||||
b.WriteString(" && claude")
|
||||
|
||||
if a.SessionID != "" {
|
||||
@@ -99,42 +106,91 @@ func (a SessionAction) bashScript() string {
|
||||
|
||||
// --- 启动辅助 ---
|
||||
|
||||
// powershellExe 返回可用的 PowerShell 可执行文件名:优先 pwsh (PS7),找不到则回退 powershell (PS5,Windows 自带)。
|
||||
// 脚本只用 $Host.UI.RawUI、Write-Host、cd 等兼容语法,PS5 也能跑。
|
||||
func powershellExe() string {
|
||||
if _, err := exec.LookPath("pwsh"); err == nil {
|
||||
return "pwsh"
|
||||
}
|
||||
return "powershell"
|
||||
}
|
||||
|
||||
// wtExe 返回 Windows Terminal 可执行文件路径;未安装/找不到时返回空串。
|
||||
// 查找顺序:PATH 中的 wt.exe → 应用执行别名标准路径
|
||||
// %LOCALAPPDATA%\Microsoft\WindowsApps\wt.exe(WindowsApps 常不在 PATH)。
|
||||
// 找不到的常见原因:未安装 Windows Terminal(Win10 需 Store 安装)、
|
||||
// 或 WindowsApps 执行别名被禁用。调用方据此回退到独立窗口启动。
|
||||
func wtExe() string {
|
||||
if p, err := exec.LookPath("wt.exe"); err == nil {
|
||||
return p
|
||||
}
|
||||
if local := os.Getenv("LOCALAPPDATA"); local != "" {
|
||||
p := filepath.Join(local, "Microsoft", "WindowsApps", "wt.exe")
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func launchInWtWithTitle(dir, tabTitle, script string) {
|
||||
encoded := encodePSCommand(script)
|
||||
color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255))
|
||||
args := []string{"-w", "0", "-d", dir, "--tabColor", color}
|
||||
if tabTitle != "" {
|
||||
args = append(args, "--title", tabTitle)
|
||||
ps := powershellExe()
|
||||
|
||||
// 优先 Windows Terminal:在已有窗口(-w 0)里开新标签页
|
||||
if wt := wtExe(); wt != "" {
|
||||
color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255))
|
||||
args := []string{"-w", "0", "-d", dir, "--tabColor", color}
|
||||
if tabTitle != "" {
|
||||
args = append(args, "--title", tabTitle)
|
||||
}
|
||||
args = append(args, ps, "-NoExit", "-EncodedCommand", encoded)
|
||||
if err := exec.Command(wt, args...).Start(); err == nil {
|
||||
return
|
||||
} else {
|
||||
log.Printf("[u-tabs] launch fail (wt): %v, fallback to cmd start", err)
|
||||
}
|
||||
}
|
||||
args = append(args, "pwsh", "-NoExit", "-EncodedCommand", encoded)
|
||||
cmd := exec.Command("wt.exe", args...)
|
||||
|
||||
// 回退:未安装 Windows Terminal → cmd start 开独立 PowerShell 窗口
|
||||
// start 语法:START ["title"] [options] command [args]
|
||||
// 首个参数被引号包裹时才视为窗口标题,否则视为命令。
|
||||
// Go 的 exec.Command 只在参数含空格时自动添加引号,
|
||||
// 所以给 tabTitle 追加空格(如尚不含空格)来确保被引号包裹。
|
||||
titleArg := tabTitle
|
||||
// 转义标题中的 cmd 特殊字符,防止命令注入:
|
||||
// 双引号全部移除;& | < > ^ 前加 ^(顺序:先 ^ 再其余)
|
||||
titleArg = strings.ReplaceAll(titleArg, `"`, "")
|
||||
titleArg = strings.ReplaceAll(titleArg, "^", "^^")
|
||||
titleArg = strings.ReplaceAll(titleArg, "&", "^&")
|
||||
titleArg = strings.ReplaceAll(titleArg, "|", "^|")
|
||||
titleArg = strings.ReplaceAll(titleArg, "<", "^<")
|
||||
titleArg = strings.ReplaceAll(titleArg, ">", "^>")
|
||||
if titleArg != "" && !strings.ContainsAny(titleArg, " \t") {
|
||||
titleArg += " " // 迫使 Go 自动加引号,窗口标题尾部空格不影响使用
|
||||
}
|
||||
args := []string{"/c", "start", titleArg, ps, "-NoExit", "-EncodedCommand", encoded}
|
||||
cmd := exec.Command("cmd.exe", args...)
|
||||
cmd.Dir = dir
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("[u-tabs] launch fail: %v", err)
|
||||
log.Printf("[u-tabs] launch fail (cmd): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildResumeAction 构建恢复会话动作
|
||||
func buildResumeAction(s *Session) SessionAction {
|
||||
title := s.CustomTitle
|
||||
if title == "" {
|
||||
title = s.ID[:8]
|
||||
}
|
||||
return SessionAction{
|
||||
Cwd: s.Cwd,
|
||||
Title: "resume: " + title,
|
||||
Title: "resume: " + s.DisplayTitle(),
|
||||
SessionID: s.ID,
|
||||
}
|
||||
}
|
||||
|
||||
// buildForkAction 构建分叉会话动作
|
||||
func buildForkAction(s *Session, prompt string) SessionAction {
|
||||
title := s.CustomTitle
|
||||
if title == "" {
|
||||
title = s.ID[:8]
|
||||
}
|
||||
return SessionAction{
|
||||
Cwd: s.Cwd,
|
||||
Title: "fork: " + title,
|
||||
Title: "fork: " + s.DisplayTitle(),
|
||||
SessionID: s.ID,
|
||||
Fork: true,
|
||||
Prompt: prompt,
|
||||
@@ -169,7 +225,7 @@ func (m *Model) resumeSelected() (*Model, tea.Cmd) {
|
||||
}
|
||||
action := buildResumeAction(s)
|
||||
action.Execute(m)
|
||||
m.launched = "resume: " + s.CustomTitle
|
||||
m.launched = "resume: " + s.DisplayTitle()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -202,11 +258,15 @@ func (m *Model) executeFork() (*Model, tea.Cmd) {
|
||||
m.forkBuf = ""
|
||||
action.Execute(m)
|
||||
|
||||
title := s.CustomTitle
|
||||
if title == "" {
|
||||
title = s.ID[:8]
|
||||
}
|
||||
m.launched = "fork: " + title
|
||||
// 记录待绑定 fork:新会话 ID 由 claude CLI 生成,此刻拿不到,
|
||||
// 留到下次扫描时按 cwd + 时间窗口匹配
|
||||
m.pendingForks = append(m.pendingForks, pendingFork{
|
||||
SourceID: s.ID,
|
||||
Cwd: s.Cwd,
|
||||
At: time.Now(),
|
||||
})
|
||||
|
||||
m.launched = "fork: " + s.DisplayTitle()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWtExeNeverReturnsBareName 锁定 wtExe 的契约:要么返回真实路径,要么返回空串,
|
||||
// 绝不能返回裸 "wt.exe"。
|
||||
// 裸 "wt.exe" 在未安装 Windows Terminal 的机器上会让 exec.Command 启动报
|
||||
// `exec: "wt.exe": executable file not found in %PATH%`(v0.2.2 的崩溃根因)。
|
||||
// 空串触发 launchInWtWithTitle 的 cmd /c start 独立窗口回退。
|
||||
func TestWtExeNeverReturnsBareName(t *testing.T) {
|
||||
got := wtExe()
|
||||
if got == "wt.exe" {
|
||||
t.Fatalf("wtExe() returned bare %q; must return full path or empty string", got)
|
||||
}
|
||||
if got != "" && !strings.HasSuffix(got, "wt.exe") {
|
||||
t.Fatalf("wtExe() returned unexpected path %q", got)
|
||||
}
|
||||
t.Logf("wtExe() = %q (empty ⇒ wt absent → cmd fallback)", got)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetUserTitleBackfillCacheEntry 验证缓存缺 entry 时(缓存文件损坏/
|
||||
// 新会话未入缓存)重命名会补建 entry,保证标题落盘而非只改内存
|
||||
func TestSetUserTitleBackfillCacheEntry(t *testing.T) {
|
||||
m := NewModel()
|
||||
m.history.Cache = map[string]*cacheEntry{}
|
||||
|
||||
m.setUserTitle("missing-session", "新标题")
|
||||
|
||||
e, ok := m.history.Cache["missing-session"]
|
||||
if !ok {
|
||||
t.Fatal("缓存缺 entry 时应补建")
|
||||
}
|
||||
if e.UserTitle != "新标题" {
|
||||
t.Fatalf("补建 entry 应写入标题, got %q", e.UserTitle)
|
||||
}
|
||||
if !m.cacheDirty {
|
||||
t.Fatal("应置 cacheDirty 调度落盘")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
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
|
||||
}
|
||||
m.Skills.Content = truncateSkillContent(string(data), 100*1024)
|
||||
m.Skills.ContentPath = path
|
||||
m.Skills.ContentScroll = 0
|
||||
}
|
||||
|
||||
// truncateSkillContent 限制内容大小避免大文件卡顿;截断点可能落在
|
||||
// UTF-8 多字节序列中间,逐字节回退到合法边界,避免尾部出现乱码
|
||||
func truncateSkillContent(content string, limit int) string {
|
||||
if len(content) <= limit {
|
||||
return content
|
||||
}
|
||||
content = content[:limit]
|
||||
for len(content) > 0 {
|
||||
if r, size := utf8.DecodeLastRuneInString(content); r != utf8.RuneError || size > 1 {
|
||||
break
|
||||
}
|
||||
content = content[:len(content)-1]
|
||||
}
|
||||
return content + "\n\n... (文件过大,截断显示)"
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const truncSuffix = "\n\n... (文件过大,截断显示)"
|
||||
|
||||
// TestTruncateSkillContentRuneBoundary 验证截断点落在多字节序列中间时
|
||||
// 逐字节回退到合法 rune 边界,结果不含残缺字节
|
||||
func TestTruncateSkillContentRuneBoundary(t *testing.T) {
|
||||
limit := 100 * 1024
|
||||
// 中文各 3 字节:分别构造截断点落在中文字符第 1、2 字节后的场景
|
||||
for _, cutInChar := range []int{1, 2} {
|
||||
prefix := strings.Repeat("a", limit-cutInChar)
|
||||
content := prefix + "中文内容超出限制"
|
||||
got := truncateSkillContent(content, limit)
|
||||
body := strings.TrimSuffix(got, truncSuffix)
|
||||
if !utf8.ValidString(body) {
|
||||
t.Fatalf("截断点落在字符第 %d 字节后, 结果应为合法 UTF-8", cutInChar)
|
||||
}
|
||||
if !strings.HasPrefix(body, prefix) {
|
||||
t.Fatalf("截断点落在字符第 %d 字节后, 不应吞掉 ASCII 前缀", cutInChar)
|
||||
}
|
||||
if len(body) > limit {
|
||||
t.Fatalf("截断点落在字符第 %d 字节后, body 长度 %d 超限 %d", cutInChar, len(body), limit)
|
||||
}
|
||||
if !strings.HasSuffix(got, truncSuffix) {
|
||||
t.Fatalf("截断后应带省略提示后缀, got %q", got[len(got)-40:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTruncateSkillContentEdge 验证边界:内容不超限原样返回,恰在边界截断不误删
|
||||
func TestTruncateSkillContentEdge(t *testing.T) {
|
||||
limit := 100 * 1024
|
||||
short := "短内容"
|
||||
if got := truncateSkillContent(short, limit); got != short {
|
||||
t.Fatalf("未超限内容应原样返回, got %q", got)
|
||||
}
|
||||
// 恰好 limit 字节后跟多字节字符:截断点在边界上,不应回退删掉合法内容
|
||||
exact := strings.Repeat("a", limit)
|
||||
got := truncateSkillContent(exact+"中文", limit)
|
||||
body := strings.TrimSuffix(got, truncSuffix)
|
||||
if body != exact {
|
||||
t.Fatalf("边界截断不应删减合法内容, body 长度 = %d, 期望 %d", len(body), limit)
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,12 @@ var (
|
||||
Red = lipgloss.Color("#f7768e") // red
|
||||
|
||||
// ── component styles ──
|
||||
TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(Accent)
|
||||
TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(Accent)
|
||||
SubtitleStyle = lipgloss.NewStyle().Foreground(Dim)
|
||||
|
||||
TabActiveStyle = lipgloss.NewStyle().Bold(true).Background(BgPanel).Foreground(Bright).Padding(0, 1)
|
||||
TabActiveStyle = lipgloss.NewStyle().Bold(true).Background(BgPanel).Foreground(Bright).Padding(0, 1)
|
||||
TabInactiveStyle = lipgloss.NewStyle().Foreground(Dim).Padding(0, 1)
|
||||
TabSep = lipgloss.NewStyle().Foreground(BgPanel)
|
||||
TabSep = lipgloss.NewStyle().Foreground(BgPanel)
|
||||
|
||||
SelStyle = lipgloss.NewStyle().Foreground(BgDark).Background(Purple).Bold(true)
|
||||
NormStyle = lipgloss.NewStyle().Foreground(Fg)
|
||||
|
||||
+5
-5
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
// Version 当前版本,发布时更新
|
||||
const Version = "0.2.2"
|
||||
const Version = "0.2.4"
|
||||
|
||||
// --- 远程 JSON 结构 ---
|
||||
|
||||
@@ -25,10 +25,10 @@ type platformInfo struct {
|
||||
}
|
||||
|
||||
type versionInfo struct {
|
||||
Version string `json:"version"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Changelog string `json:"changelog"`
|
||||
Platforms map[string]platformInfo `json:"platforms"`
|
||||
Version string `json:"version"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Changelog string `json:"changelog"`
|
||||
Platforms map[string]platformInfo `json:"platforms"`
|
||||
}
|
||||
|
||||
// --- bubbletea 消息类型 ---
|
||||
|
||||
+32
-1
@@ -4,15 +4,46 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// atomicWriteFile 原子写文件:先写临时文件再 rename,避免写入中途崩溃导致目标文件损坏
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
tmp.Close()
|
||||
if err := os.Chmod(tmpName, perm); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, path)
|
||||
}
|
||||
|
||||
// 平台信息(启动时确定)
|
||||
var (
|
||||
isWindows = runtime.GOOS == "windows"
|
||||
isWindows = runtime.GOOS == "windows"
|
||||
platformKey = runtime.GOOS + "-" + runtime.GOARCH
|
||||
)
|
||||
|
||||
|
||||
+29
-4
@@ -4,9 +4,10 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"u-tabs/internal/style"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// --- View 入口 ---
|
||||
@@ -33,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())
|
||||
}
|
||||
@@ -66,7 +69,7 @@ func (m *Model) render() string {
|
||||
|
||||
helpParts := m.renderHelp()
|
||||
helpStr := " " + strings.Join(helpParts, " ")
|
||||
verStr := style.DimFg.Render("绝尘 v" + Version)
|
||||
verStr := style.DimFg.Render("U-TABS v" + Version)
|
||||
padW := m.width - lipgloss.Width(helpStr) - lipgloss.Width(verStr)
|
||||
if padW > 0 {
|
||||
b.WriteString(helpStr + strings.Repeat(" ", padW) + verStr)
|
||||
@@ -101,16 +104,37 @@ 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"),
|
||||
m.fmtHelp("←→", "panel"),
|
||||
m.fmtHelp("Enter", "resume"),
|
||||
m.fmtHelp("f", "star"),
|
||||
m.fmtHelp("e", "rename"),
|
||||
m.fmtHelp("n", "new"),
|
||||
m.fmtHelp("c", "fork"),
|
||||
m.fmtHelp("1", "workspace"),
|
||||
@@ -123,13 +147,14 @@ func (m *Model) renderHelp() []string {
|
||||
m.fmtHelp("↑↓", "sel"),
|
||||
m.fmtHelp("Enter", "run"),
|
||||
m.fmtHelp("2", "history"),
|
||||
m.fmtHelp("3", "skills"),
|
||||
m.fmtHelp("q", "quit"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) fmtHelp(key, desc string) string {
|
||||
return style.AccentFg.Render(key) +
|
||||
style.DimFg.Render(":" + desc)
|
||||
style.DimFg.Render(":"+desc)
|
||||
}
|
||||
|
||||
func renderNavEntry(lines *[]string, w int, label string, activeFg string, isCur, focused bool) {
|
||||
|
||||
+55
-1
@@ -198,7 +198,61 @@ func (m *Model) detailColumnLines(w, listH int) []string {
|
||||
if m.history.Favorites[s.ID] {
|
||||
favPrefix = "★ "
|
||||
}
|
||||
lines = append(lines, style.DetailTitle.Render(fitWidth(favPrefix+s.DisplayTitle(), w)))
|
||||
// 标题行:编辑模式显示输入框,否则显示普通标题
|
||||
if m.editTitleMode {
|
||||
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)))
|
||||
} else {
|
||||
lines = append(lines, style.DetailTitle.Render(fitWidth(favPrefix+s.DisplayTitle(), w)))
|
||||
}
|
||||
lines = append(lines, style.SepFg.Render(fitWidth(sepRunes(w), w)))
|
||||
|
||||
fav := "-"
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
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))}
|
||||
}
|
||||
// 先为所有技能累积全部行(含描述行),再按行数视口切片,
|
||||
// 避免按条目数计算视口导致变长行撑爆面板
|
||||
type skillBlock struct {
|
||||
lines []string
|
||||
}
|
||||
var blocks []skillBlock
|
||||
totalLines := 0
|
||||
curLineOffset := 0
|
||||
|
||||
for i, sk := range skills {
|
||||
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
|
||||
}
|
||||
blockLines := []string{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
|
||||
}
|
||||
blockLines = append(blockLines, descSty.Render(padRightByWidth(truncateByWidth(descLine, w), w)))
|
||||
}
|
||||
if i == m.Skills.SkillCur {
|
||||
curLineOffset = totalLines
|
||||
}
|
||||
totalLines += len(blockLines)
|
||||
blocks = append(blocks, skillBlock{lines: blockLines})
|
||||
}
|
||||
|
||||
start, end := viewport(curLineOffset, totalLines, listH)
|
||||
|
||||
var allLines []string
|
||||
for _, b := range blocks {
|
||||
allLines = append(allLines, b.lines...)
|
||||
}
|
||||
if end > len(allLines) {
|
||||
end = len(allLines)
|
||||
}
|
||||
return allLines[start:end]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSkillsListLinesViewportByLines 验证技能列表视口按行数(含描述行)收口,
|
||||
// 返回行数不超过 listH,避免变长行撑爆面板
|
||||
func TestSkillsListLinesViewportByLines(t *testing.T) {
|
||||
m := NewModel()
|
||||
m.Skills.Loaded = true
|
||||
for i := 0; i < 3; i++ {
|
||||
m.Skills.Skills = append(m.Skills.Skills, &SkillInfo{
|
||||
Name: "skill-" + strings.Repeat("x", i),
|
||||
Description: "描述行 " + string(rune('A'+i)),
|
||||
})
|
||||
}
|
||||
m.Skills.SkillCur = 0
|
||||
m.Skills.FocusPanel = 0
|
||||
|
||||
listH := 3 // 足够只显示部分(总共 6 行:3 技能 × 2 行)
|
||||
lines := m.skillsListLines(30, listH)
|
||||
if len(lines) > listH {
|
||||
t.Fatalf("skillsListLines 应返回 ≤ %d 行, got %d", listH, len(lines))
|
||||
}
|
||||
|
||||
// 光标在首个技能时,视口应从头开始且光标行可见
|
||||
if !strings.Contains(lines[0], "▸") {
|
||||
t.Fatalf("首行应含光标标记, got %q", lines[0])
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
@@ -11,6 +11,22 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// --fix-titles: 清理缓存中的低质量 AI 摘要(Claude 困惑回复/噪音/过短),
|
||||
// 下次启动会重新扫描并重新生成。一次性数据维护命令,不进入 TUI。
|
||||
if len(os.Args) > 1 && os.Args[1] == "--fix-titles" {
|
||||
n := internal.CleanupBadTitles()
|
||||
fmt.Printf("已清理 %d 个低质量摘要,重启 u-tabs 后将自动重新生成\n", n)
|
||||
return
|
||||
}
|
||||
|
||||
// --regen-summaries: 立即并发生成所有缺失的 AI 摘要(AISummary 为空的会话)。
|
||||
// 通常在 --fix-titles 之后运行,把清理掉的标题重新解析回来。不进入 TUI。
|
||||
if len(os.Args) > 1 && os.Args[1] == "--regen-summaries" {
|
||||
ok, fail := internal.RegenerateSummaries(4)
|
||||
fmt.Printf("摘要重新生成完成: 成功 %d, 降级 %d\n", ok, fail)
|
||||
return
|
||||
}
|
||||
|
||||
internal.InitData()
|
||||
m := internal.NewModel()
|
||||
p := tea.NewProgram(m, tea.WithFPS(0))
|
||||
|
||||
Reference in New Issue
Block a user