Compare commits
7
Commits
baec416df7
...
cc58de4661
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc58de4661 | ||
|
|
558b676eea | ||
|
|
d54d0c4c63 | ||
|
|
95f349bcd7 | ||
|
|
043a7d9227 | ||
|
|
ec2603444d | ||
|
|
45e32193af |
@@ -5,6 +5,9 @@ u-tabs-linux
|
|||||||
whale-windows-amd64.exe
|
whale-windows-amd64.exe
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Vendor (依赖由 go.mod 管理,不纳入版本控制)
|
||||||
|
vendor/
|
||||||
|
|
||||||
# Config
|
# Config
|
||||||
config.yaml
|
config.yaml
|
||||||
|
|
||||||
|
|||||||
+84
-3
@@ -3,9 +3,10 @@ package internal
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Model 主模型
|
// Model 主模型
|
||||||
@@ -22,14 +23,19 @@ type Model struct {
|
|||||||
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), 最后一个是历史入口)
|
||||||
|
Skills SkillsState
|
||||||
forkMode bool // 分叉输入模式
|
forkMode bool // 分叉输入模式
|
||||||
forkBuf string // 分叉方向提示输入
|
forkBuf string // 分叉方向提示输入
|
||||||
|
editTitleMode bool // 编辑标题模式
|
||||||
|
editTitleBuf string // 标题编辑缓冲区
|
||||||
|
editTitleCursor int // 标题光标位置(rune 索引)
|
||||||
|
|
||||||
// 性能缓存
|
// 性能缓存
|
||||||
allSessionsCache []*Session // "全部" tab 会话切片缓存
|
allSessionsCache []*Session // "全部" tab 会话切片缓存
|
||||||
allSessionsDirty bool // 缓存是否需要重建
|
allSessionsDirty bool // 缓存是否需要重建
|
||||||
cacheDirty bool // session-cache.json 是否需要写盘
|
cacheDirty bool // session-cache.json 是否需要写盘
|
||||||
summaryCount int // 本次会话已生成的摘要数
|
summaryCount int // 本次会话已生成的摘要数
|
||||||
|
pendingForks []pendingFork // executeFork 留下的待绑定项,扫描后解析
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel() *Model {
|
func NewModel() *Model {
|
||||||
@@ -56,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
|
||||||
@@ -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
|
total := 0
|
||||||
for _, pd := range msg.Projects {
|
for _, pd := range msg.Projects {
|
||||||
total += len(pd.Sessions)
|
total += len(pd.Sessions)
|
||||||
}
|
}
|
||||||
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)
|
||||||
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
|
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)
|
||||||
@@ -114,6 +155,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.QuitMsg:
|
case tea.QuitMsg:
|
||||||
|
// 最终落盘:扫描未完成时 Cache 可能为 nil,此时无数据可写,
|
||||||
|
// 已生成的摘要会在下次启动扫描时由 cache 维护
|
||||||
m.flushCache()
|
m.flushCache()
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
@@ -124,6 +167,44 @@ func (m *Model) GetPendingCmd() string {
|
|||||||
return m.pendingCmd
|
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() {
|
func (m *Model) flushCache() {
|
||||||
if !m.cacheDirty || m.history.Cache == nil {
|
if !m.cacheDirty || m.history.Cache == nil {
|
||||||
return
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1296
-48
File diff suppressed because it is too large
Load Diff
+263
-8
@@ -1,12 +1,15 @@
|
|||||||
package internal
|
package internal
|
||||||
|
|
||||||
import (
|
import tea "charm.land/bubbletea/v2"
|
||||||
"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 等被全局拦截
|
||||||
|
if m.editTitleMode {
|
||||||
|
return m.handleEditTitleInput(s)
|
||||||
|
}
|
||||||
|
|
||||||
switch s {
|
switch s {
|
||||||
case "q", "ctrl+c":
|
case "q", "ctrl+c":
|
||||||
return m, tea.Quit
|
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 s == "1" {
|
||||||
if m.IsHistoryTab() {
|
m.editTitleMode = false
|
||||||
|
m.editTitleBuf = ""
|
||||||
|
if m.IsHistoryTab() || m.IsSkillsTab() {
|
||||||
m.activeGroup = 0
|
m.activeGroup = 0
|
||||||
m.resetCursor()
|
m.resetCursor()
|
||||||
m.wsFocus = 1
|
m.wsFocus = 1
|
||||||
@@ -35,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)
|
||||||
}
|
}
|
||||||
@@ -50,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) {
|
||||||
@@ -81,18 +232,23 @@ 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 {
|
||||||
return m.launchSelected()
|
return m.launchSelected()
|
||||||
}
|
}
|
||||||
default:
|
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
|
m.inputBuf += s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,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
|
||||||
@@ -119,6 +275,9 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
|
|||||||
if m.forkMode {
|
if m.forkMode {
|
||||||
return m.handleForkInput(s)
|
return m.handleForkInput(s)
|
||||||
}
|
}
|
||||||
|
if m.editTitleMode {
|
||||||
|
return m.handleEditTitleInput(s)
|
||||||
|
}
|
||||||
|
|
||||||
switch s {
|
switch s {
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
@@ -161,6 +320,14 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
case "f":
|
case "f":
|
||||||
m.toggleFavorite()
|
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":
|
case "c":
|
||||||
if m.currentSession() != nil && m.history.FocusPanel == 1 {
|
if m.currentSession() != nil && m.history.FocusPanel == 1 {
|
||||||
m.forkMode = true
|
m.forkMode = true
|
||||||
@@ -170,6 +337,92 @@ func (m *Model) handleHistoryKey(s string) (*Model, tea.Cmd) {
|
|||||||
return m, nil
|
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) {
|
func (m *Model) handleForkInput(s string) (*Model, tea.Cmd) {
|
||||||
switch s {
|
switch s {
|
||||||
case "enter":
|
case "enter":
|
||||||
@@ -184,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
|
||||||
|
|||||||
+84
-24
@@ -3,12 +3,14 @@ package internal
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SessionAction 描述一个 Claude Code 会话启动动作
|
// 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))
|
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"=== %s ===\" -ForegroundColor Cyan", a.Title))
|
||||||
|
|
||||||
if a.Prompt != "" && a.Name == "" {
|
if a.Prompt != "" && a.Name == "" {
|
||||||
// workspace prompt 显示
|
// workspace prompt 显示(转义双引号,避免破坏 PowerShell 脚本)
|
||||||
b.WriteString(fmt.Sprintf("\n\t\tWrite-Host \"Prompt: %s\" -ForegroundColor Yellow", a.Prompt))
|
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)))
|
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))
|
b.WriteString(fmt.Sprintf(`printf '\033]0;%s\007' && echo "=== %s ==="`, a.Title, a.Title))
|
||||||
|
|
||||||
if a.Prompt != "" && a.Name == "" {
|
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")
|
b.WriteString(" && claude")
|
||||||
|
|
||||||
if a.SessionID != "" {
|
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) {
|
func launchInWtWithTitle(dir, tabTitle, script string) {
|
||||||
encoded := encodePSCommand(script)
|
encoded := encodePSCommand(script)
|
||||||
|
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))
|
color := fmt.Sprintf("#%02X%02X%02X", randRange(80, 255), randRange(80, 255), randRange(80, 255))
|
||||||
args := []string{"-w", "0", "-d", dir, "--tabColor", color}
|
args := []string{"-w", "0", "-d", dir, "--tabColor", color}
|
||||||
if tabTitle != "" {
|
if tabTitle != "" {
|
||||||
args = append(args, "--title", tabTitle)
|
args = append(args, "--title", tabTitle)
|
||||||
}
|
}
|
||||||
args = append(args, "pwsh", "-NoExit", "-EncodedCommand", encoded)
|
args = append(args, ps, "-NoExit", "-EncodedCommand", encoded)
|
||||||
cmd := exec.Command("wt.exe", args...)
|
if err := exec.Command(wt, args...).Start(); err == nil {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
log.Printf("[u-tabs] launch fail (wt): %v, fallback to cmd start", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:未安装 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 {
|
if err := cmd.Start(); err != nil {
|
||||||
log.Printf("[u-tabs] launch fail: %v", err)
|
log.Printf("[u-tabs] launch fail (cmd): %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildResumeAction 构建恢复会话动作
|
// buildResumeAction 构建恢复会话动作
|
||||||
func buildResumeAction(s *Session) SessionAction {
|
func buildResumeAction(s *Session) SessionAction {
|
||||||
title := s.CustomTitle
|
|
||||||
if title == "" {
|
|
||||||
title = s.ID[:8]
|
|
||||||
}
|
|
||||||
return SessionAction{
|
return SessionAction{
|
||||||
Cwd: s.Cwd,
|
Cwd: s.Cwd,
|
||||||
Title: "resume: " + title,
|
Title: "resume: " + s.DisplayTitle(),
|
||||||
SessionID: s.ID,
|
SessionID: s.ID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildForkAction 构建分叉会话动作
|
// buildForkAction 构建分叉会话动作
|
||||||
func buildForkAction(s *Session, prompt string) SessionAction {
|
func buildForkAction(s *Session, prompt string) SessionAction {
|
||||||
title := s.CustomTitle
|
|
||||||
if title == "" {
|
|
||||||
title = s.ID[:8]
|
|
||||||
}
|
|
||||||
return SessionAction{
|
return SessionAction{
|
||||||
Cwd: s.Cwd,
|
Cwd: s.Cwd,
|
||||||
Title: "fork: " + title,
|
Title: "fork: " + s.DisplayTitle(),
|
||||||
SessionID: s.ID,
|
SessionID: s.ID,
|
||||||
Fork: true,
|
Fork: true,
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
@@ -169,7 +225,7 @@ func (m *Model) resumeSelected() (*Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
action := buildResumeAction(s)
|
action := buildResumeAction(s)
|
||||||
action.Execute(m)
|
action.Execute(m)
|
||||||
m.launched = "resume: " + s.CustomTitle
|
m.launched = "resume: " + s.DisplayTitle()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,11 +258,15 @@ func (m *Model) executeFork() (*Model, tea.Cmd) {
|
|||||||
m.forkBuf = ""
|
m.forkBuf = ""
|
||||||
action.Execute(m)
|
action.Execute(m)
|
||||||
|
|
||||||
title := s.CustomTitle
|
// 记录待绑定 fork:新会话 ID 由 claude CLI 生成,此刻拿不到,
|
||||||
if title == "" {
|
// 留到下次扫描时按 cwd + 时间窗口匹配
|
||||||
title = s.ID[:8]
|
m.pendingForks = append(m.pendingForks, pendingFork{
|
||||||
}
|
SourceID: s.ID,
|
||||||
m.launched = "fork: " + title
|
Cwd: s.Cwd,
|
||||||
|
At: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
m.launched = "fork: " + s.DisplayTitle()
|
||||||
return m, nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -14,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Version 当前版本,发布时更新
|
// Version 当前版本,发布时更新
|
||||||
const Version = "0.2.2"
|
const Version = "0.2.4"
|
||||||
|
|
||||||
// --- 远程 JSON 结构 ---
|
// --- 远程 JSON 结构 ---
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,43 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf16"
|
"unicode/utf16"
|
||||||
"unicode/utf8"
|
"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 (
|
var (
|
||||||
isWindows = runtime.GOOS == "windows"
|
isWindows = runtime.GOOS == "windows"
|
||||||
|
|||||||
+29
-4
@@ -4,9 +4,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
"u-tabs/internal/style"
|
"u-tabs/internal/style"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- View 入口 ---
|
// --- View 入口 ---
|
||||||
@@ -33,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())
|
||||||
}
|
}
|
||||||
@@ -66,7 +69,7 @@ func (m *Model) render() string {
|
|||||||
|
|
||||||
helpParts := m.renderHelp()
|
helpParts := m.renderHelp()
|
||||||
helpStr := " " + strings.Join(helpParts, " ")
|
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)
|
padW := m.width - lipgloss.Width(helpStr) - lipgloss.Width(verStr)
|
||||||
if padW > 0 {
|
if padW > 0 {
|
||||||
b.WriteString(helpStr + strings.Repeat(" ", padW) + verStr)
|
b.WriteString(helpStr + strings.Repeat(" ", padW) + verStr)
|
||||||
@@ -101,16 +104,37 @@ 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"),
|
||||||
m.fmtHelp("←→", "panel"),
|
m.fmtHelp("←→", "panel"),
|
||||||
m.fmtHelp("Enter", "resume"),
|
m.fmtHelp("Enter", "resume"),
|
||||||
m.fmtHelp("f", "star"),
|
m.fmtHelp("f", "star"),
|
||||||
|
m.fmtHelp("e", "rename"),
|
||||||
m.fmtHelp("n", "new"),
|
m.fmtHelp("n", "new"),
|
||||||
m.fmtHelp("c", "fork"),
|
m.fmtHelp("c", "fork"),
|
||||||
m.fmtHelp("1", "workspace"),
|
m.fmtHelp("1", "workspace"),
|
||||||
@@ -123,13 +147,14 @@ 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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) fmtHelp(key, desc string) string {
|
func (m *Model) fmtHelp(key, desc string) string {
|
||||||
return style.AccentFg.Render(key) +
|
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) {
|
func renderNavEntry(lines *[]string, w int, label string, activeFg string, isCur, focused bool) {
|
||||||
|
|||||||
@@ -198,7 +198,61 @@ func (m *Model) detailColumnLines(w, listH int) []string {
|
|||||||
if m.history.Favorites[s.ID] {
|
if m.history.Favorites[s.ID] {
|
||||||
favPrefix = "★ "
|
favPrefix = "★ "
|
||||||
}
|
}
|
||||||
|
// 标题行:编辑模式显示输入框,否则显示普通标题
|
||||||
|
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.DetailTitle.Render(fitWidth(favPrefix+s.DisplayTitle(), w)))
|
||||||
|
}
|
||||||
lines = append(lines, style.SepFg.Render(fitWidth(sepRunes(w), w)))
|
lines = append(lines, style.SepFg.Render(fitWidth(sepRunes(w), w)))
|
||||||
|
|
||||||
fav := "-"
|
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 {
|
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
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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()
|
internal.InitData()
|
||||||
m := internal.NewModel()
|
m := internal.NewModel()
|
||||||
p := tea.NewProgram(m, tea.WithFPS(0))
|
p := tea.NewProgram(m, tea.WithFPS(0))
|
||||||
|
|||||||
Reference in New Issue
Block a user