@zhushanwen/pi-session-reader 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +1 -0
- package/package.json +47 -0
- package/src/__tests__/family.test.ts +207 -0
- package/src/__tests__/find.test.ts +214 -0
- package/src/__tests__/hash-provider.test.ts +498 -0
- package/src/__tests__/index.test.ts +193 -0
- package/src/__tests__/parser.test.ts +166 -0
- package/src/__tests__/real-data.ts +53 -0
- package/src/__tests__/render.test.ts +367 -0
- package/src/__tests__/roots.test.ts +132 -0
- package/src/__tests__/session-command.test.ts +198 -0
- package/src/__tests__/subagents.test.ts +430 -0
- package/src/__tests__/tool-handler.test.ts +510 -0
- package/src/__tests__/toolcall.test.ts +256 -0
- package/src/__tests__/tree.test.ts +101 -0
- package/src/__tests__/turns.test.ts +142 -0
- package/src/core/family.ts +240 -0
- package/src/core/parser.ts +160 -0
- package/src/core/render.ts +584 -0
- package/src/core/toolcall.ts +168 -0
- package/src/core/tree.ts +93 -0
- package/src/core/turns.ts +97 -0
- package/src/discovery/find.ts +247 -0
- package/src/discovery/roots.ts +92 -0
- package/src/discovery/subagents.ts +470 -0
- package/src/index.ts +189 -0
- package/src/tool-handler.ts +1160 -0
- package/src/tui/hash-provider.ts +230 -0
- package/src/tui/session-command.ts +85 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { AutocompleteProvider, AutocompleteItem } from '@earendil-works/pi-tui'
|
|
2
|
+
import { SessionManager, type SessionInfo } from '@earendil-works/pi-coding-agent'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* M4 TUI 层:# 引用补全(design §3.3 D-3/D-4 + §1 目标 4 + 附录 P-hash-trigger)。
|
|
6
|
+
*
|
|
7
|
+
* 数据源(2026-08-10 重构):显示候选复用 pi 的 `SessionManager.listAll(cwdSessionDir)`——
|
|
8
|
+
* 由 pi 维护文件解析、cwd 目录定位、session_info.name 提取、并发读,extension 只做
|
|
9
|
+
* SessionInfo → AutocompleteItem 的 UI 映射。零自写扫描逻辑(一致性 > 品味)。
|
|
10
|
+
*
|
|
11
|
+
* **insertText 方案(完整 uuid)**:insertText = `#` + 完整 sessionId(36 字符 uuid v7)。
|
|
12
|
+
* 完整 uuid 天然全局唯一,findSessions 的 `sessionId.includes(query)` 子串匹配对完整 uuid
|
|
13
|
+
* 零碰撞(完整 uuid 是某 sessionId 的完整子串 = 唯一命中)。无需算唯一前缀、无需全局扫,
|
|
14
|
+
* 一致性零维护(旧 LCP + 全局扫方案已删除)。用户历史 session 里手敲的短片段仍能 find
|
|
15
|
+
*(子串匹配 + 碰撞时 F2 消歧兜底)。
|
|
16
|
+
*
|
|
17
|
+
* 分层:
|
|
18
|
+
* - 纯逻辑(extractHashFragment / formatAge / toCandidate / provideHashCandidates):
|
|
19
|
+
* cwdSessionDir 注入,可单测(造真实 session 文件让 listAll 真跑,不 mock)
|
|
20
|
+
* - createHashAutocompleteProvider:pi-tui AutocompleteProvider 接口适配,组装在 index.ts
|
|
21
|
+
*
|
|
22
|
+
* design D-3:# 选中插入完整 uuid(#xxxxxxxx-xxxx-...),不插入名称。
|
|
23
|
+
* design D-4:插入纯文本 # uuid,不展开——工具侧(tool-handler)剥 # 前缀后按子串匹配。
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** # 补全默认返回上限(TUI 弹窗可读上限,design G6)。 */
|
|
27
|
+
const DEFAULT_LIMIT = 10
|
|
28
|
+
/**
|
|
29
|
+
* description(预览/name)最大字符数。SessionInfo.firstMessage 是首消息全文(可能含
|
|
30
|
+
* `<skill>` 注入全文,上千字符),预截断避免传超大字符串给 pi-tui。100 覆盖到 ~140 列
|
|
31
|
+
* 终端的 description 区(= width − 主列固定32 − prefix2 − safety2)。
|
|
32
|
+
*/
|
|
33
|
+
const PREVIEW_MAX = 100
|
|
34
|
+
|
|
35
|
+
// formatAge 时间换算常数(design G4:对齐 /resume formatSessionDate 的单单位语义)
|
|
36
|
+
const MS_PER_MINUTE = 60_000
|
|
37
|
+
const MS_PER_HOUR = 3_600_000
|
|
38
|
+
const MS_PER_DAY = 86_400_000
|
|
39
|
+
const MINUTES_PER_HOUR = 60
|
|
40
|
+
const HOURS_PER_DAY = 24
|
|
41
|
+
const DAYS_PER_WEEK = 7
|
|
42
|
+
const DAYS_PER_MONTH = 30
|
|
43
|
+
const DAYS_PER_YEAR = 365
|
|
44
|
+
/** formatAge 数字部分补零宽度(design G4:固定等宽 XXu) */
|
|
45
|
+
const AGE_NUM_DIGITS = 2
|
|
46
|
+
|
|
47
|
+
export interface AutocompleteCandidate {
|
|
48
|
+
/** 显示文本(满宽 label)。`${age} ${预览/name}`,如 "01m 看看 pi-session-reader..." */
|
|
49
|
+
label: string
|
|
50
|
+
/** 副信息(次列)。本 provider 不设(undefined)——触发 SelectList 满宽 label 分支 */
|
|
51
|
+
description?: string
|
|
52
|
+
/** 插入编辑器,如 "#019e6c96-aaaa-bbbb-cccc-dddddddddddd"(design D-3:完整 uuid,非名称;不显示给用户看) */
|
|
53
|
+
insertText: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 从光标前文本提取 # 片段。
|
|
58
|
+
*
|
|
59
|
+
* 匹配规则:行内 `#` 后跟 0+ 个十六进制/连字符字符(uuid 片段特征),且 `#` 位于 token
|
|
60
|
+
* 边界(行首或非单词字符之后)——避免 `foo#bar`、`C#` 这类 hashtag/语言符号误触发。
|
|
61
|
+
*
|
|
62
|
+
* @param textBeforeCursor 光标前的当前行文本(provider wrapper 传 currentLine.slice(0, cursorCol))
|
|
63
|
+
* @returns 片段(不含 `#`,空串表示刚输入 `#`);非 # 引用 → null(调用方据此委托下家 provider)
|
|
64
|
+
*/
|
|
65
|
+
export function extractHashFragment(textBeforeCursor: string): string | null {
|
|
66
|
+
const m = textBeforeCursor.match(/#([0-9a-f-]*)$/i)
|
|
67
|
+
if (!m || m.index === undefined) return null
|
|
68
|
+
// # 前必须是 token 边界:行首,或前一个字符非单词字符(空格/标点)
|
|
69
|
+
if (m.index > 0) {
|
|
70
|
+
const prev = textBeforeCursor[m.index - 1]
|
|
71
|
+
if (/\w/.test(prev)) return null
|
|
72
|
+
}
|
|
73
|
+
return m[1]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* modified → 固定等宽的单单位时间(对齐用,design G4)。
|
|
78
|
+
*
|
|
79
|
+
* 格式:`now`(<1分钟)或 `XXu`(2位数字补零 + 1 位单位)。单位 m/h/d/w/M/y 全单字符,
|
|
80
|
+
* 总宽 3 字符严格对齐(月用 `M` 区分分钟的 `m`)。对齐 pi `/resume` 的 formatSessionDate
|
|
81
|
+
* 单单位语义,但补零到 2 位 + 压缩月单位以等宽(用户要求「保留2位数字+一位单位」)。
|
|
82
|
+
*/
|
|
83
|
+
export function formatAge(modified: Date | number, now: number = Date.now()): string {
|
|
84
|
+
const ms = typeof modified === 'number' ? modified : modified.getTime()
|
|
85
|
+
const diff = now - ms
|
|
86
|
+
if (diff < MS_PER_MINUTE) return 'now'
|
|
87
|
+
const mins = Math.floor(diff / MS_PER_MINUTE)
|
|
88
|
+
if (mins < MINUTES_PER_HOUR) return `${String(mins).padStart(AGE_NUM_DIGITS, '0')}m`
|
|
89
|
+
const hours = Math.floor(diff / MS_PER_HOUR)
|
|
90
|
+
if (hours < HOURS_PER_DAY) return `${String(hours).padStart(AGE_NUM_DIGITS, '0')}h`
|
|
91
|
+
const days = Math.floor(diff / MS_PER_DAY)
|
|
92
|
+
if (days < DAYS_PER_WEEK) return `${String(days).padStart(AGE_NUM_DIGITS, '0')}d`
|
|
93
|
+
if (days < DAYS_PER_MONTH) return `${String(Math.floor(days / DAYS_PER_WEEK)).padStart(AGE_NUM_DIGITS, '0')}w`
|
|
94
|
+
if (days < DAYS_PER_YEAR) return `${String(Math.floor(days / DAYS_PER_MONTH)).padStart(AGE_NUM_DIGITS, '0')}M`
|
|
95
|
+
return `${String(Math.floor(days / DAYS_PER_YEAR)).padStart(AGE_NUM_DIGITS, '0')}y`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 控制字符/换行 → 单空格(避免 description 带换行破坏 SelectList 单行渲染)。 */
|
|
99
|
+
function normalizeSingleLine(s: string | undefined): string {
|
|
100
|
+
if (!s) return ''
|
|
101
|
+
return s.replace(/[\x00-\x1f\x7f]+/g, ' ').trim()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 按字符数截断(预览用,CJK 宽度边缘情况接受 pi-tui 最终裁剪更保守)。 */
|
|
105
|
+
function truncate(s: string, max: number): string {
|
|
106
|
+
return s.length <= max ? s : s.slice(0, max) + '…'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* SessionInfo → AutocompleteCandidate(纯函数,可单测)。
|
|
111
|
+
*
|
|
112
|
+
* **绕过 pi-tui 主列固定32死约束的关键**:不设 description。SelectList.renderItem 在
|
|
113
|
+
* description 为 undefined 时走 else 分支,把 label 当整行截到 width-4 满宽,**不应用主列
|
|
114
|
+
* 固定32的分列逻辑**(select-list.js renderItem:`if (descriptionSingleLine && width>40)`
|
|
115
|
+
* 分支才进主列逻辑,否则 label 满宽)。
|
|
116
|
+
*
|
|
117
|
+
* 映射:
|
|
118
|
+
* - label = `${age} ${预览/name}`(如 "01m 看看 pi-session-reader...")——满宽渲染,
|
|
119
|
+
* 时间最左 + 1 空格 + 预览吃满,无 padding,不显示 uuid,不含 count(用户反馈)。
|
|
120
|
+
* - description = undefined(不设)——触发上述满宽分支。
|
|
121
|
+
* - insertText = `#${s.id}`(design D-3:完整 36 字符 uuid)。完整 uuid 天然全局唯一,
|
|
122
|
+
* findSessions 子串匹配零碰撞——无需算唯一前缀、无需全局扫。
|
|
123
|
+
*
|
|
124
|
+
* name 优先于 firstMessage(design G3)。不清洗 XML 标签(对齐 /resume)。只清洗控制字符/换行。
|
|
125
|
+
*
|
|
126
|
+
* @param now 计算 age 的基准时间(默认当前)
|
|
127
|
+
*/
|
|
128
|
+
export function toCandidate(s: SessionInfo, now: number = Date.now()): AutocompleteCandidate {
|
|
129
|
+
const age = formatAge(s.modified, now)
|
|
130
|
+
const text = truncate(normalizeSingleLine(s.name ?? s.firstMessage), PREVIEW_MAX) || '(无预览)'
|
|
131
|
+
return {
|
|
132
|
+
label: `${age} ${text}`,
|
|
133
|
+
insertText: `#${s.id}`,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 核心逻辑:光标前文本 → # 引用候选(design §3.3 D-3 + 完整 uuid insertText)。
|
|
139
|
+
*
|
|
140
|
+
* **显示候选**:`SessionManager.listAll(cwdSessionDir)`——pi 返回当前 cwd 目录的全部
|
|
141
|
+
* session(含 name/messageCount/firstMessage,已按 modified 倒序),per-cwd,快(~19ms)。
|
|
142
|
+
*
|
|
143
|
+
* **insertText**:始终完整 uuid(`#${s.id}`)。完整 uuid 天然全局唯一,findSessions
|
|
144
|
+
* `sessionId.includes(query)` 子串匹配对完整 uuid 零碰撞。无需区分 fragment 空/非空、
|
|
145
|
+
* 无需全局扫。
|
|
146
|
+
*
|
|
147
|
+
* @param input 光标前的文本(provider wrapper 传 currentLine.slice(0, cursorCol))
|
|
148
|
+
* @param cwdSessionDir 当前 session 的目录(ctx.sessionManager.getSessionDir(),含 encoded cwd)
|
|
149
|
+
* @param opts.limit 返回上限(默认 10)
|
|
150
|
+
* @returns 非 # 前缀 → null(委托下家 provider);# 前缀 → 候选数组(无匹配为空数组,不抛)
|
|
151
|
+
*/
|
|
152
|
+
export async function provideHashCandidates(
|
|
153
|
+
input: string,
|
|
154
|
+
cwdSessionDir: string,
|
|
155
|
+
opts?: { limit?: number },
|
|
156
|
+
): Promise<AutocompleteCandidate[] | null> {
|
|
157
|
+
const fragment = extractHashFragment(input)
|
|
158
|
+
if (fragment === null) return null
|
|
159
|
+
// 目录未就绪(session_start 前的异常窗口)→ 返回空,绝不调 listAll('')——
|
|
160
|
+
// pi 的 listAll 对空字符串 falsy 走默认全盘分支(3488 项 / ~8s),会让 # 弹窗卡死
|
|
161
|
+
if (!cwdSessionDir) return []
|
|
162
|
+
const limit = opts?.limit ?? DEFAULT_LIMIT
|
|
163
|
+
const all = await SessionManager.listAll(cwdSessionDir)
|
|
164
|
+
// uuid 片段非空 → id 子串过滤;空片段(刚输入 #)→ recent(listAll 已按 modified 倒序)
|
|
165
|
+
const filtered = fragment === '' ? all : all.filter((s) => s.id.includes(fragment))
|
|
166
|
+
const visible = filtered.slice(0, limit)
|
|
167
|
+
return visible.map((s) => toCandidate(s))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 创建 # autocomplete provider(实现 pi-tui AutocompleteProvider 接口)。
|
|
172
|
+
*
|
|
173
|
+
* 包装内置 current(CombinedAutocompleteProvider,处理 @ 文件 / / 命令 / 路径):
|
|
174
|
+
* - 非 # 前缀 → 显式委托 current.getSuggestions(addAutocompleteProvider 是 stack 模式,
|
|
175
|
+
* 组合责任在 factory:return null 不会被 pi 自动 fallback,必须显式调 current)
|
|
176
|
+
* - # 前缀有匹配 → 返回 session 候选
|
|
177
|
+
* - # 前缀无匹配 → return null(不委托 current,避免它把 #xxx 当路径前缀返回文件建议)
|
|
178
|
+
*
|
|
179
|
+
* applyCompletion 把 `#fragment`(光标前已输入的片段)替换为完整 `#uuid`(选中项的完整 uuid)。
|
|
180
|
+
*/
|
|
181
|
+
export function createHashAutocompleteProvider(
|
|
182
|
+
getCwdSessionDir: () => string,
|
|
183
|
+
current: AutocompleteProvider,
|
|
184
|
+
): AutocompleteProvider {
|
|
185
|
+
return {
|
|
186
|
+
triggerCharacters: ['#'],
|
|
187
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
188
|
+
// 用户快速连续输入时,pi abort 上一次请求;早返回避免无谓 IO
|
|
189
|
+
if (options.signal.aborted) return null
|
|
190
|
+
const currentLine = lines[cursorLine] ?? ''
|
|
191
|
+
const textBeforeCursor = currentLine.slice(0, cursorCol)
|
|
192
|
+
const fragment = extractHashFragment(textBeforeCursor)
|
|
193
|
+
// 非 # 前缀:委托内置 provider(@ 文件 / / 命令 / 路径补全)
|
|
194
|
+
if (fragment === null) {
|
|
195
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options)
|
|
196
|
+
}
|
|
197
|
+
// # 前缀:查 session(getter 动态读当前 session 目录,resume 后自动跟随)
|
|
198
|
+
const candidates = await provideHashCandidates(textBeforeCursor, getCwdSessionDir())
|
|
199
|
+
if (options.signal.aborted) return null
|
|
200
|
+
// provideHashCandidates 返回 null 仅在非 # 前缀(fragment===null),上面已拦截;
|
|
201
|
+
// 此处 null 是 TS 收窄的防御性检查,逻辑上不触发
|
|
202
|
+
if (candidates === null || candidates.length === 0) return null
|
|
203
|
+
const items: AutocompleteItem[] = candidates.map((c) => ({
|
|
204
|
+
value: c.insertText,
|
|
205
|
+
label: c.label,
|
|
206
|
+
description: c.description,
|
|
207
|
+
}))
|
|
208
|
+
// prefix = 光标前匹配到的整段(# 及片段),applyCompletion 据此定位替换区间
|
|
209
|
+
return { items, prefix: `#${fragment}` }
|
|
210
|
+
},
|
|
211
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
212
|
+
// 非本 provider 的 item(不应发生,pi 按 suggestion source 路由 applyCompletion)→ 委托 current
|
|
213
|
+
if (!item.value.startsWith('#')) {
|
|
214
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix)
|
|
215
|
+
}
|
|
216
|
+
const currentLine = lines[cursorLine] ?? ''
|
|
217
|
+
const before = currentLine.slice(0, cursorCol - prefix.length)
|
|
218
|
+
const after = currentLine.slice(cursorCol)
|
|
219
|
+
// 选中后自动补一个空格,让 #uuid 与后续输入隔开(LLM 解析时 uuid 片段与指令分离,
|
|
220
|
+
// 避免 #uuid查看 连写被整体当作查询串)。仅当光标后已以空白开头时不重复加(行尾也补,
|
|
221
|
+
// 用户选中后直接打字即产生间隔);光标停在补的空格之后
|
|
222
|
+
const spacer = /^\s/.test(after) ? '' : ' '
|
|
223
|
+
const insert = item.value + spacer
|
|
224
|
+
const newLine = before + insert + after
|
|
225
|
+
const newLines = lines.slice()
|
|
226
|
+
newLines[cursorLine] = newLine
|
|
227
|
+
return { lines: newLines, cursorLine, cursorCol: before.length + insert.length }
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { AutocompleteItem } from '@earendil-works/pi-tui'
|
|
2
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent'
|
|
3
|
+
import { SessionManager, type SessionInfo } from '@earendil-works/pi-coding-agent'
|
|
4
|
+
import { toCandidate } from './hash-provider.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* M4 TUI 层:/session-pick 命令(design 附录 P-hash-trigger 降级兜底 + 非 # 场景入口)。
|
|
8
|
+
*
|
|
9
|
+
* 两条用途:
|
|
10
|
+
* 1. # autocomplete provider 在真实 TUI 触发失败(⛔ P-hash-trigger)时的降级入口——
|
|
11
|
+
* 用户输入 /session-pick 走列表选择,选中后插入 # 完整 uuid
|
|
12
|
+
* 2. 非 # 主动查找场景(用户明确想浏览 session 列表)
|
|
13
|
+
*
|
|
14
|
+
* 显示数据源与 # 弹窗统一(2026-08-10 重构):`SessionManager.listAll(cwdSessionDir)`,
|
|
15
|
+
* limit 对齐 10(design G6),cwd-scoped + uuid 片段过滤(design G1)。
|
|
16
|
+
*
|
|
17
|
+
* **insertText**:与 hash-provider 一致,命令参数 value(剥 #)和 handler 选中后插入的
|
|
18
|
+
* # 引用都用完整 uuid。完整 uuid 天然全局唯一,findSessions `sessionId.includes(query)`
|
|
19
|
+
* 子串匹配零碰撞,无需全局扫算唯一前缀。
|
|
20
|
+
*
|
|
21
|
+
* cwdSessionDir 注入(同 hash-provider,零 pi 依赖核心逻辑,可单测)。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** /session-pick 列表上限(对齐 # 弹窗 DEFAULT_LIMIT,design G6)。 */
|
|
25
|
+
const PICK_LIMIT = 10
|
|
26
|
+
|
|
27
|
+
/** label 追加的短 uuid 长度(uuid v7 时间前缀,同目录同毫秒创建概率可忽略,消歧足够)。 */
|
|
28
|
+
const SHORT_UUID_LEN = 8
|
|
29
|
+
|
|
30
|
+
/** /session-pick 命令配置(Omit<RegisteredCommand, 'name' | 'sourceInfo'>)。 */
|
|
31
|
+
export function createSessionCommand(
|
|
32
|
+
getCwdSessionDir: () => string,
|
|
33
|
+
): {
|
|
34
|
+
description: string
|
|
35
|
+
getArgumentCompletions(argumentPrefix: string): Promise<AutocompleteItem[] | null>
|
|
36
|
+
handler(args: string, ctx: ExtensionCommandContext): Promise<void>
|
|
37
|
+
} {
|
|
38
|
+
return {
|
|
39
|
+
description: 'Pick a session and insert a #uuid-fragment reference into the editor.',
|
|
40
|
+
async getArgumentCompletions(argumentPrefix) {
|
|
41
|
+
const trimmed = argumentPrefix.trim()
|
|
42
|
+
const all = await SessionManager.listAll(getCwdSessionDir())
|
|
43
|
+
// uuid 片段过滤(与 # 弹窗一致);空 prefix → recent(listAll 已按 modified 倒序)
|
|
44
|
+
const filtered =
|
|
45
|
+
trimmed === '' ? all : all.filter((s) => s.id.includes(trimmed))
|
|
46
|
+
const top = filtered.slice(0, PICK_LIMIT)
|
|
47
|
+
if (top.length === 0) return null
|
|
48
|
+
// value 用完整 uuid(剥 # 后,命令参数位置不带 #)
|
|
49
|
+
return top.map((s) => {
|
|
50
|
+
const c = toCandidate(s)
|
|
51
|
+
return { value: c.insertText.slice(1), label: c.label }
|
|
52
|
+
})
|
|
53
|
+
},
|
|
54
|
+
async handler(args, ctx) {
|
|
55
|
+
const trimmed = args.trim()
|
|
56
|
+
const all = await SessionManager.listAll(getCwdSessionDir())
|
|
57
|
+
const filtered =
|
|
58
|
+
trimmed === '' ? all : all.filter((s) => s.id.includes(trimmed))
|
|
59
|
+
const top = filtered.slice(0, PICK_LIMIT)
|
|
60
|
+
if (top.length === 0) {
|
|
61
|
+
ctx.ui.notify('未找到匹配的 session。', 'warning')
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
// ctx.ui.select 只接受 string[]、返回选中的字符串。用并行数组 + indexOf 还原 session。
|
|
65
|
+
const labels = top.map((s) => formatSessionLabel(s))
|
|
66
|
+
const chosen = await ctx.ui.select('选择一个 session 插入 # 引用', labels)
|
|
67
|
+
if (chosen === undefined) return
|
|
68
|
+
const idx = labels.indexOf(chosen)
|
|
69
|
+
if (idx < 0) return
|
|
70
|
+
// 选中后插入 # + 完整 uuid(剥 # 算片段,再补 # 插入编辑器)
|
|
71
|
+
const frag = toCandidate(top[idx]).insertText.slice(1)
|
|
72
|
+
// /session-pick 提交后编辑器已清空,直接 set # uuid 供用户补完指令再发送;
|
|
73
|
+
// 尾部补空格与 # 弹窗 applyCompletion spacer 语义一致(S-2),避免 #uuid查看 连写被整体当查询串
|
|
74
|
+
ctx.ui.setEditorText(`#${frag} `)
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** select 列表单行格式:toCandidate label + 尾部短 uuid(消歧)。
|
|
80
|
+
* 原始 label = `{age桶}{预览截100字}`,不含 uuid:同 cwd 下两 session 首条消息相同且落同一
|
|
81
|
+
* age 桶时 label 完全相同 → ctx.ui.select 返回的字符串经 labels.indexOf 反查会错插 uuid(MF-2)。
|
|
82
|
+
* 追加 uuid 前缀(slice(0, SHORT_UUID_LEN),uuid v7 时间前缀,同目录同毫秒创建概率可忽略)保证唯一。 */
|
|
83
|
+
function formatSessionLabel(s: SessionInfo): string {
|
|
84
|
+
return `${toCandidate(s).label} ${s.id.slice(0, SHORT_UUID_LEN)}`
|
|
85
|
+
}
|