@zhushanwen/pi-ask-user 0.1.0 → 1.0.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/package.json +9 -2
- package/src/__tests__/answer-format.test.ts +107 -0
- package/src/__tests__/channel-handler.test.ts +286 -0
- package/src/__tests__/channel-registry-register.test.ts +180 -0
- package/src/__tests__/component-keymap.test.ts +0 -31
- package/src/__tests__/e2e-harness.ts +6 -0
- package/src/__tests__/e2e.test.ts +2 -2
- package/src/__tests__/editor-ops.test.ts +179 -0
- package/src/__tests__/index.test.ts +312 -24
- package/src/__tests__/question-view.test.ts +50 -35
- package/src/__tests__/sdk-contract.test.ts +1 -1
- package/src/__tests__/validate.test.ts +5 -3
- package/src/__tests__/w2-draft-hint.test.ts +0 -89
- package/src/__tests__/w3-regression.test.ts +5 -187
- package/src/answer-format.ts +51 -0
- package/src/channel-handler.ts +215 -0
- package/src/channel-registry-register.ts +108 -0
- package/src/component.ts +42 -101
- package/src/editor-ops.ts +75 -0
- package/src/index.ts +211 -69
- package/src/question-view.ts +32 -47
- package/src/submit-view.ts +2 -4
- package/src/validate.ts +11 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// src/answer-format.ts
|
|
2
|
+
// 答案文本格式的唯一权威模块。
|
|
3
|
+
// TUI 路径(submit-view.ts:getAnswerText)和 RPC 路径(index.ts:protoAnswersToResult)
|
|
4
|
+
// 都调 formatAnswer 产出 "label1, label2 — comment" 格式,确保两条路径一致。
|
|
5
|
+
// renderExpandedOptions(index.ts)调 parseAnswerParts 精确反解析选中项。
|
|
6
|
+
import { ANSWER_COMMENT_SEPARATOR } from "./types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 把答案各部分拼装为最终文本格式:"part1, part2 — comment"。
|
|
10
|
+
* - parts 为空 → 返回 null(未答)
|
|
11
|
+
* - comment 有值 → 追加 ANSWER_COMMENT_SEPARATOR + comment
|
|
12
|
+
*/
|
|
13
|
+
export function formatAnswer(parts: string[], comment?: string | null): string | null {
|
|
14
|
+
if (parts.length === 0) return null;
|
|
15
|
+
const base = parts.join(", ");
|
|
16
|
+
return comment ? `${base}${ANSWER_COMMENT_SEPARATOR}${comment}` : base;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 从最终答案文本中精确解析出选中的 labels(不依赖子串匹配)。
|
|
21
|
+
* 用于 renderExpandedOptions 反向判定哪些选项被选中。
|
|
22
|
+
*
|
|
23
|
+
* @param answer 最终答案文本(formatAnswer 产出)
|
|
24
|
+
* @param labels 候选 label 列表(q.options 的 label),精确匹配
|
|
25
|
+
* @returns selected=命中的 labels(按 answer 中出现顺序),comment=评论文本(如有)
|
|
26
|
+
*/
|
|
27
|
+
export function parseAnswerParts(
|
|
28
|
+
answer: string,
|
|
29
|
+
labels: string[],
|
|
30
|
+
): { selected: string[]; comment?: string } {
|
|
31
|
+
// 先提取 comment(ANSWER_COMMENT_SEPARATOR 之后的部分)
|
|
32
|
+
let body = answer;
|
|
33
|
+
let comment: string | undefined;
|
|
34
|
+
const sepIdx = answer.indexOf(ANSWER_COMMENT_SEPARATOR);
|
|
35
|
+
if (sepIdx >= 0) {
|
|
36
|
+
body = answer.slice(0, sepIdx);
|
|
37
|
+
comment = answer.slice(sepIdx + ANSWER_COMMENT_SEPARATOR.length).trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// body 形如 "label1, label2" → 精确匹配候选 label
|
|
41
|
+
const labelSet = new Set(labels);
|
|
42
|
+
const tokens = body.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
|
|
43
|
+
const selected: string[] = [];
|
|
44
|
+
// 剩余 tokens 不匹配任何 label → 是 Other 自由文本(不返回,调用方自行处理)
|
|
45
|
+
for (const token of tokens) {
|
|
46
|
+
if (labelSet.has(token)) {
|
|
47
|
+
selected.push(token);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { selected, comment };
|
|
51
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// src/channel-handler.ts
|
|
2
|
+
//
|
|
3
|
+
// ask_user channel handler:把 subagent 子进程的 ask_user 请求透传到主进程 UI 渲染。
|
|
4
|
+
//
|
|
5
|
+
// 设计(关键决策):askUserInteract(@xyz-agent/extension-protocol)只在 RPC 模式可用
|
|
6
|
+
// (内部 isGuiCapable 检查 mode==='rpc',TUI 下抛错)。所以 handler 按 ctx.mode 分流:
|
|
7
|
+
// - RPC:转发器——调 askUserInteract(guiCtx, protoQuestions),复用 select 通道 +
|
|
8
|
+
// ASK_USER_MARKER 契约,主进程 ctx.ui.select 经 GUI sidecar 渲染(不进 parseSpawnLine,
|
|
9
|
+
// 不循环)。返回 {value: JSON.stringify(answers)} 让子进程 JSON.parse(value) decode。
|
|
10
|
+
// - TUI:走 ctx.ui.custom + AskUserComponent。三步:(1) protoQuestions → 内部 Question[],
|
|
11
|
+
// (2) ctx.ui.custom 渲染拿内部 Result,(3) 内部 Result.answers(key=question 全文,
|
|
12
|
+
// value="label1, label2 — comment")→ 重新编码为 proto AskUserAnswers(key=header/question,
|
|
13
|
+
// 单选=value,多选=JSON 数组,Other→__other,comment→__comment),让子进程 decode 一致。
|
|
14
|
+
//
|
|
15
|
+
// handler 收到的 req.channelPayload = {questions: AskUserQuestion[], allowCancel}(proto 格式,
|
|
16
|
+
// 由子进程 askUserInteract 编码、subagent-workflow parseChannel 解析 options[0] JSON 得到)。
|
|
17
|
+
|
|
18
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
19
|
+
import {
|
|
20
|
+
type AskUserAnswers,
|
|
21
|
+
askUserInteract,
|
|
22
|
+
type AskUserQuestion,
|
|
23
|
+
} from "@xyz-agent/extension-protocol";
|
|
24
|
+
|
|
25
|
+
import { AskUserComponent } from "./component";
|
|
26
|
+
import { ANSWER_COMMENT_SEPARATOR, type Option, type Question, type Result, type ThemeLike } from "./types";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* channel handler 签名——与 subagent-workflow 的 UiChannelRegistry.ChannelHandler 一致
|
|
30
|
+
*((req: unknown) => Promise<unknown>)。本文件不静态 import subagent-workflow(它是可选
|
|
31
|
+
* peerDep,未安装时静态 import 会导致整个 ask-user 加载失败);注册时通过动态 import 拿
|
|
32
|
+
* registry,handler 签名用本地等价类型,运行时结构兼容。
|
|
33
|
+
*/
|
|
34
|
+
export type ChannelHandler = (req: unknown) => Promise<unknown>;
|
|
35
|
+
|
|
36
|
+
/** handler 返回给 subagent-workflow 的 UiResponse 形状(dialog-queue.ts 定义)。
|
|
37
|
+
* - {value}: select 的回传值(子进程 JSON.parse(value) 得 answers)
|
|
38
|
+
* - {cancelled}: 用户取消 / 子进程 close / handler 抛错 */
|
|
39
|
+
type ChannelResponse = { value: string } | { cancelled: true };
|
|
40
|
+
|
|
41
|
+
/** handler 收到的 req 形状收窄(ChannelHandler 签名是 unknown,按形状 as 收窄)。
|
|
42
|
+
* channelPayload 由 subagent-workflow parseChannel 填充。 */
|
|
43
|
+
interface ChannelRequest {
|
|
44
|
+
channelPayload?: { questions?: AskUserQuestion[]; allowCancel?: boolean };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** proto AskUserQuestion → 内部 Question(AskUserComponent 接受内部格式)。
|
|
48
|
+
* proto options 可选(无 options=纯自由文本),内部 options 必填——无 options 的 protoQuestion
|
|
49
|
+
* 这里仍映射出 options(从子进程 ask-user 调用方保证 protoQuestions 总带 options;若缺则返 [] 由调用方判)。 */
|
|
50
|
+
function protoToInternalQuestions(protoQuestions: AskUserQuestion[]): Question[] {
|
|
51
|
+
return protoQuestions.map((pq: AskUserQuestion): Question => {
|
|
52
|
+
const opts: Option[] = (pq.options ?? []).map((o: { label: string; description?: string }): Option => ({
|
|
53
|
+
label: o.label,
|
|
54
|
+
...(o.description !== undefined ? { description: o.description } : {}),
|
|
55
|
+
}));
|
|
56
|
+
return {
|
|
57
|
+
question: pq.question,
|
|
58
|
+
...(pq.header !== undefined ? { header: pq.header } : {}),
|
|
59
|
+
...(pq.context !== undefined ? { context: pq.context } : {}),
|
|
60
|
+
options: opts,
|
|
61
|
+
...(pq.multiSelect !== undefined ? { multiSelect: pq.multiSelect } : {}),
|
|
62
|
+
...(pq.allowComment !== undefined ? { allowComment: pq.allowComment } : {}),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 把 TUI 路径产出的内部 Result.answers 重新编码为 proto AskUserAnswers。
|
|
69
|
+
*
|
|
70
|
+
* 内部 Result.answers:key = question 全文,value = "label1, label2 — comment"
|
|
71
|
+
* (Other 自由文本与 selected 标签逗号拼接,comment 用 ANSWER_COMMENT_SEPARATOR 分隔)。
|
|
72
|
+
*
|
|
73
|
+
* proto AskUserAnswers 契约(@xyz-agent/extension-protocol):
|
|
74
|
+
* - key = question.header ?? question 全文
|
|
75
|
+
* - 单选:value = 选中项 value string
|
|
76
|
+
* - 多选:value = JSON.stringify(选中项 value 数组)
|
|
77
|
+
* - Other 自由文本:单独 key `${header}__other`
|
|
78
|
+
* - comment:单独 key `${header}__comment`
|
|
79
|
+
*
|
|
80
|
+
* 解码(无信息丢失):用 protoQuestion.options 的 label 集合精确匹配 selected;
|
|
81
|
+
* 不匹配的 token = Other 自由文本;comment 由 ANSWER_COMMENT_SEPARATOR 切出。
|
|
82
|
+
*/
|
|
83
|
+
function encodeTuiResultToProto(
|
|
84
|
+
protoQuestions: AskUserQuestion[],
|
|
85
|
+
result: Result,
|
|
86
|
+
): AskUserAnswers {
|
|
87
|
+
const answers: AskUserAnswers = {};
|
|
88
|
+
for (const pq of protoQuestions) {
|
|
89
|
+
const key = pq.header ?? pq.question;
|
|
90
|
+
const internalText = result.answers[pq.question];
|
|
91
|
+
if (internalText === undefined) continue; // 该问题未答(protoAnswersToResult 也跳过未答)
|
|
92
|
+
|
|
93
|
+
// 已知选项 label 集合(protoQuestion.options 的 label/value 都作候选——value 缺失时用 label)
|
|
94
|
+
const knownLabels = new Set<string>();
|
|
95
|
+
for (const o of pq.options ?? []) {
|
|
96
|
+
knownLabels.add(o.label);
|
|
97
|
+
if (o.value !== undefined) knownLabels.add(o.value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 切 body / comment(comment 在 ANSWER_COMMENT_SEPARATOR 之后)
|
|
101
|
+
const sepIdx = internalText.indexOf(ANSWER_COMMENT_SEPARATOR);
|
|
102
|
+
const body = sepIdx >= 0 ? internalText.slice(0, sepIdx) : internalText;
|
|
103
|
+
const comment = sepIdx >= 0
|
|
104
|
+
? internalText.slice(sepIdx + ANSWER_COMMENT_SEPARATOR.length).trim() || undefined
|
|
105
|
+
: undefined;
|
|
106
|
+
|
|
107
|
+
// body tokens:匹配 knownLabels 的为 selected,其余为 Other 自由文本
|
|
108
|
+
const tokens = body.split(/[,,]/).map((t: string) => t.trim()).filter((t: string) => t !== "");
|
|
109
|
+
const selected: string[] = [];
|
|
110
|
+
const otherTokens: string[] = [];
|
|
111
|
+
for (const t of tokens) {
|
|
112
|
+
if (knownLabels.has(t)) {
|
|
113
|
+
// 回查 proto option 的 value(PR #85 #8):TUI 渲染用 label,但 RPC 路径
|
|
114
|
+
// (askUserInteract)回传的是 option.value。value≠label 时若直接 push label,
|
|
115
|
+
// TUI/RPC 两条路径产出分裂。value 缺失时 fallback label(保持 ask-user 自身
|
|
116
|
+
// toProtoQuestions 的 value=label 语义,以及历史行为)。
|
|
117
|
+
const opt = pq.options?.find(o => o.label === t || o.value === t);
|
|
118
|
+
selected.push(opt?.value ?? t);
|
|
119
|
+
} else {
|
|
120
|
+
otherTokens.push(t);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const otherText = otherTokens.join(", ") || undefined;
|
|
124
|
+
|
|
125
|
+
// 主 key:单选 = 首个选中 value;多选 = JSON 数组(即便为空也写入,与 RPC 契约一致)
|
|
126
|
+
if (pq.multiSelect) {
|
|
127
|
+
answers[key] = JSON.stringify(selected);
|
|
128
|
+
} else if (selected.length > 0) {
|
|
129
|
+
answers[key] = selected[0]!;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (otherText) answers[`${key}__other`] = otherText;
|
|
133
|
+
if (comment) answers[`${key}__comment`] = comment;
|
|
134
|
+
}
|
|
135
|
+
return answers;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** TUI 路径:ctx.ui.custom + AskUserComponent 渲染,返回 proto answers 或 null(取消)。
|
|
139
|
+
*
|
|
140
|
+
* allowCancel 透传预留(PR #85 #12):AskUserComponent 构造函数暂未接收 allowCancel,
|
|
141
|
+
* Esc 取消始终可用(component.ts 的 escBackOrConfirm / cancel 无条件生效)。待组件升级
|
|
142
|
+
* 支持禁用 Esc 后,应把 allowCancel 下传给 AskUserComponent 构造函数。当前 allowCancel=false
|
|
143
|
+
* 时 TUI 与 RPC 路径仍有分裂,但 handler 层已不再吞掉 allowCancel(修复分裂的第一步)。 */
|
|
144
|
+
async function runTuiProtoInteraction(
|
|
145
|
+
protoQuestions: AskUserQuestion[],
|
|
146
|
+
ctx: ExtensionContext,
|
|
147
|
+
allowCancel: boolean,
|
|
148
|
+
): Promise<AskUserAnswers | null> {
|
|
149
|
+
const questions = protoToInternalQuestions(protoQuestions);
|
|
150
|
+
// 预留:组件升级后此处改为 new AskUserComponent(questions, tui, theme, done, allowCancel)
|
|
151
|
+
void allowCancel;
|
|
152
|
+
const result = await ctx.ui.custom<Result | null>(
|
|
153
|
+
(tui: unknown, theme: unknown, _kb: unknown, done: (r: Result | null) => void) => {
|
|
154
|
+
const comp = new AskUserComponent(
|
|
155
|
+
questions,
|
|
156
|
+
tui as { requestRender(): void },
|
|
157
|
+
theme as ThemeLike,
|
|
158
|
+
done,
|
|
159
|
+
);
|
|
160
|
+
return comp;
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
if (result === null || result.cancelled) return null;
|
|
164
|
+
return encodeTuiResultToProto(protoQuestions, result);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 创建 ask_user channel handler。
|
|
169
|
+
*
|
|
170
|
+
* @param ctx 主进程 ExtensionContext(session_start 时注入)
|
|
171
|
+
* @returns ChannelHandler——req.channelPayload = {questions, allowCancel}(proto 格式),
|
|
172
|
+
* 返回 {value: JSON.stringify(answers)} 或 {cancelled: true}
|
|
173
|
+
*/
|
|
174
|
+
export function createAskUserChannelHandler(ctx: ExtensionContext): ChannelHandler {
|
|
175
|
+
return async (req: unknown): Promise<unknown> => {
|
|
176
|
+
// req 正常是 subagent-workflow 构造的 UiRequest 对象;防御性收窄 null/undefined/
|
|
177
|
+
// 非 object(handler 抛错会被 dialog-queue 兜底为 {cancelled:true},但这里直接返回更干净)
|
|
178
|
+
if (req === null || typeof req !== "object") {
|
|
179
|
+
return { cancelled: true } satisfies ChannelResponse;
|
|
180
|
+
}
|
|
181
|
+
const r = req as ChannelRequest;
|
|
182
|
+
const payload = r.channelPayload;
|
|
183
|
+
if (!payload || !Array.isArray(payload.questions) || payload.questions.length === 0) {
|
|
184
|
+
return { cancelled: true } satisfies ChannelResponse;
|
|
185
|
+
}
|
|
186
|
+
const { questions, allowCancel } = payload;
|
|
187
|
+
|
|
188
|
+
// 按 ctx.mode 分流(PR #85 #13 / #M6):rpc 走 askUserInteract(select 通道+sidecar),
|
|
189
|
+
// 其余(tui/json/print/undefined)走 ctx.ui.custom+AskUserComponent。
|
|
190
|
+
// 用 ctx.mode === "rpc" 二值判定(与 index.ts execute 的 useRpc 判定一致);
|
|
191
|
+
// 三值分类不需要——handler 只关心「rpc 转发」vs「TUI 内部渲染」两条路径。
|
|
192
|
+
const answers =
|
|
193
|
+
ctx.mode === "rpc"
|
|
194
|
+
? await runRpcForward(questions, ctx, allowCancel ?? true)
|
|
195
|
+
: await runTuiProtoInteraction(questions, ctx, allowCancel ?? true);
|
|
196
|
+
|
|
197
|
+
if (answers === null) return { cancelled: true } satisfies ChannelResponse;
|
|
198
|
+
return { value: JSON.stringify(answers) } satisfies ChannelResponse;
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** RPC 转发器:主进程 ctx.ui.select 经 GUI sidecar 渲染(不进 parseSpawnLine,不循环)。
|
|
203
|
+
* 完整复用 askUserInteract 的 encode/decode 契约。 */
|
|
204
|
+
async function runRpcForward(
|
|
205
|
+
questions: AskUserQuestion[],
|
|
206
|
+
ctx: ExtensionContext,
|
|
207
|
+
allowCancel: boolean,
|
|
208
|
+
): Promise<AskUserAnswers | null> {
|
|
209
|
+
const guiCtx = {
|
|
210
|
+
mode: ctx.mode,
|
|
211
|
+
hasUI: ctx.hasUI,
|
|
212
|
+
ui: { select: ctx.ui.select.bind(ctx.ui) },
|
|
213
|
+
};
|
|
214
|
+
return askUserInteract(guiCtx, questions, { allowCancel });
|
|
215
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// src/channel-registry-register.ts
|
|
2
|
+
//
|
|
3
|
+
// ask-user 侧的 channel handler 握手注册纯函数。
|
|
4
|
+
//
|
|
5
|
+
// 设计动机(PR #85 #M4 修复):原实现 ask-user 先 session_start 时会自建简化 Map-based
|
|
6
|
+
// registry 占据 canonical 槽位,劫持 subagent-workflow 后续 getOrCreateChannelRegistry 拿到的
|
|
7
|
+
// 实例(subagent-workflow 的 createUiChannelRegistry 才是 canonical——它带排队、dialog 队列
|
|
8
|
+
// 等完整能力)。修复后的握手协议改为「带 version 的 slot」:
|
|
9
|
+
// - subagent-workflow session_start 时往 slot 写 {version, registry, pending:[]}
|
|
10
|
+
// - ask-user session_start 时只读 slot,registry 就绪则调 registry.register,未就绪则 push pending
|
|
11
|
+
// - ask-user **永不**创建 registry 实例,**永不**写 slot.registry
|
|
12
|
+
//
|
|
13
|
+
// 这是 ask-user 侧的注册入口;canonical registry 由 subagent-workflow 创建。本模块永不创建
|
|
14
|
+
// registry 实例——仅往 slot 写 pending 或调 slot.registry.register。
|
|
15
|
+
|
|
16
|
+
import type { ChannelHandler } from "./channel-handler";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 进程级 channel registry 握手的 globalThis key(Symbol.for 跨模块共享)。
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ 必须与 extensions/subagent-workflow/src/execution/channel-registry-access.ts 的字面量
|
|
22
|
+
* 完全一致——两边用同一字符串确保拿到同一 slot 实例。改名必须两侧同步。
|
|
23
|
+
*/
|
|
24
|
+
export const CHANNEL_HANDSHAKE_KEY = Symbol.for(
|
|
25
|
+
"@zhushanwen/pi-subagents.channelHandshake",
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/** 握手协议版本号。读写 slot 时校验 version !== 1 视为不兼容(warn + 重建 slot)。 */
|
|
29
|
+
const HANDSHAKE_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
/** channel 名称(ask-user 固定注册 "ask_user")。 */
|
|
32
|
+
const ASK_USER_CHANNEL = "ask_user";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* channel registry 的本地等价接口(与 subagent-workflow UiChannelRegistry 形状一致)。
|
|
36
|
+
* 本模块不静态 import subagent-workflow(它是可选 peerDep,未安装时静态 import 会致整个
|
|
37
|
+
* ask-user 加载失败);运行时结构兼容即可。
|
|
38
|
+
*/
|
|
39
|
+
interface ChannelRegistry {
|
|
40
|
+
register(channel: string, handler: ChannelHandler): void;
|
|
41
|
+
resolve(channel: string): ChannelHandler | undefined;
|
|
42
|
+
list(): string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** pending 队列元素:channel + handler。subagent-workflow flush 时遍历调用 registry.register。 */
|
|
46
|
+
interface PendingEntry {
|
|
47
|
+
channel: string;
|
|
48
|
+
handler: ChannelHandler;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* globalThis slot 的形状。
|
|
53
|
+
*
|
|
54
|
+
* - `version`:握手协议版本(运行时校验,不兼容则丢弃重建)
|
|
55
|
+
* - `registry`:canonical 实例,**仅 subagent-workflow 创建**;缺失表示 registry 未就绪,
|
|
56
|
+
* ask-user 把 handler 入 pending 队列等待 flush
|
|
57
|
+
* - `pending`:未消费的注册请求(registry 就绪后由 subagent-workflow 一次性 flush)
|
|
58
|
+
*/
|
|
59
|
+
export interface ChannelRegistryHandshake {
|
|
60
|
+
version: 1;
|
|
61
|
+
registry?: ChannelRegistry;
|
|
62
|
+
pending: PendingEntry[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 从 globalThis 读 slot;version !== 1 视为无 slot(返回 undefined)。 */
|
|
66
|
+
function readSlot(): ChannelRegistryHandshake | undefined {
|
|
67
|
+
const slot = Reflect.get(globalThis, CHANNEL_HANDSHAKE_KEY) as
|
|
68
|
+
| ChannelRegistryHandshake
|
|
69
|
+
| undefined;
|
|
70
|
+
if (slot === undefined) return undefined;
|
|
71
|
+
if (slot.version !== HANDSHAKE_VERSION) {
|
|
72
|
+
console.warn(
|
|
73
|
+
`[ask-user] channel handshake slot version mismatch: expected ${HANDSHAKE_VERSION}, got ${slot.version}; discarding and rebuilding slot`,
|
|
74
|
+
);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
return slot;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 在 globalThis 上建一个空 slot(仅 pending,无 registry——ask-user 永不建 registry)。 */
|
|
81
|
+
function ensureSlot(): ChannelRegistryHandshake {
|
|
82
|
+
const slot: ChannelRegistryHandshake = { version: HANDSHAKE_VERSION, pending: [] };
|
|
83
|
+
Reflect.set(globalThis, CHANNEL_HANDSHAKE_KEY, slot);
|
|
84
|
+
return slot;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 注册 ask_user channel handler 到 globalThis 握手 slot。
|
|
89
|
+
*
|
|
90
|
+
* 行为:
|
|
91
|
+
* 1. slot 不存在或 version 不兼容 → 建 slot(仅 pending),handler 入 pending;
|
|
92
|
+
* **slot.registry 保持 undefined**(M4 核心:ask-user 不建 registry)
|
|
93
|
+
* 2. slot 存在但 registry 未就绪 → handler 入 pending
|
|
94
|
+
* 3. slot 存在且 registry 就绪 → 直接调 registry.register("ask_user", handler)
|
|
95
|
+
*
|
|
96
|
+
* 多次调用幂等:registry 就绪时 register 同名覆盖;未就绪时 pending.length 增长
|
|
97
|
+
* (subagent-workflow flush 时一次性消费所有 pending)。
|
|
98
|
+
*
|
|
99
|
+
* @param handler ask_user channel handler(createAskUserChannelHandler 产出)
|
|
100
|
+
*/
|
|
101
|
+
export function registerAskUserChannelHandler(handler: ChannelHandler): void {
|
|
102
|
+
const slot = readSlot() ?? ensureSlot();
|
|
103
|
+
if (slot.registry !== undefined) {
|
|
104
|
+
slot.registry.register(ASK_USER_CHANNEL, handler);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
slot.pending.push({ channel: ASK_USER_CHANNEL, handler });
|
|
108
|
+
}
|
package/src/component.ts
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
// src/component.ts
|
|
2
2
|
import { type Component, matchesKey, parseKey, truncateToWidth } from "@mariozechner/pi-tui";
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
deleteCharBeforeCursor,
|
|
6
|
+
handleEditorPaste,
|
|
7
|
+
insertAtCursor,
|
|
8
|
+
moveCursorEnd,
|
|
9
|
+
moveCursorHome,
|
|
10
|
+
moveCursorLeft,
|
|
11
|
+
moveCursorRight,
|
|
12
|
+
} from "./editor-ops";
|
|
4
13
|
import { allOptions, renderQuestionView } from "./question-view";
|
|
5
14
|
import { buildResult, renderButtonBar, renderSubmitView } from "./submit-view";
|
|
6
15
|
import {
|
|
7
16
|
createQuestionState,
|
|
8
17
|
HEADER_MAX_CHARS,
|
|
9
|
-
isHighSurrogate,
|
|
10
18
|
type Question,
|
|
11
19
|
type QuestionState,
|
|
12
20
|
type Result,
|
|
13
|
-
SURROGATE_PAIR_LEN,
|
|
14
21
|
type ThemeLike,
|
|
15
22
|
} from "./types";
|
|
16
23
|
|
|
@@ -76,6 +83,13 @@ export class AskUserComponent implements Component {
|
|
|
76
83
|
this.cachedLines = undefined;
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
/** 状态变更后的标准后续:失效缓存 + 请求重绘。
|
|
87
|
+
* 消除散弹式修改——每个 mutation 方法末尾不再需要手写 invalidate + requestRender 两行。 */
|
|
88
|
+
private rerender(): void {
|
|
89
|
+
this.invalidate();
|
|
90
|
+
this.tui.requestRender();
|
|
91
|
+
}
|
|
92
|
+
|
|
79
93
|
// ── 渲染 ──
|
|
80
94
|
render(width: number): string[] {
|
|
81
95
|
if (this.cachedWidth === width && this.cachedLines) {
|
|
@@ -108,7 +122,7 @@ export class AskUserComponent implements Component {
|
|
|
108
122
|
} else {
|
|
109
123
|
const q = this.questions[this.activeTab]!;
|
|
110
124
|
const state = this.states[this.activeTab]!;
|
|
111
|
-
for (const line of renderQuestionView(q, state, t, innerWidth, this.isSingle
|
|
125
|
+
for (const line of renderQuestionView({ question: q, state, theme: t, width: innerWidth, isSingle: this.isSingle })) {
|
|
112
126
|
add(line);
|
|
113
127
|
}
|
|
114
128
|
}
|
|
@@ -173,8 +187,7 @@ export class AskUserComponent implements Component {
|
|
|
173
187
|
this.cancel();
|
|
174
188
|
} else {
|
|
175
189
|
this.pendingCancel = false;
|
|
176
|
-
this.
|
|
177
|
-
this.tui.requestRender();
|
|
190
|
+
this.rerender();
|
|
178
191
|
}
|
|
179
192
|
return;
|
|
180
193
|
}
|
|
@@ -221,15 +234,13 @@ export class AskUserComponent implements Component {
|
|
|
221
234
|
|
|
222
235
|
if (matchesKey(data, "up")) {
|
|
223
236
|
state.cursorIndex = Math.max(0, state.cursorIndex - 1);
|
|
224
|
-
this.
|
|
225
|
-
this.tui.requestRender();
|
|
237
|
+
this.rerender();
|
|
226
238
|
return;
|
|
227
239
|
}
|
|
228
240
|
if (matchesKey(data, "down")) {
|
|
229
241
|
const max = allOptions(q).length - 1;
|
|
230
242
|
state.cursorIndex = Math.min(max, state.cursorIndex + 1);
|
|
231
|
-
this.
|
|
232
|
-
this.tui.requestRender();
|
|
243
|
+
this.rerender();
|
|
233
244
|
return;
|
|
234
245
|
}
|
|
235
246
|
|
|
@@ -242,8 +253,7 @@ export class AskUserComponent implements Component {
|
|
|
242
253
|
state.mode = "freeform";
|
|
243
254
|
state.draftText = state.freeTextValue ?? state.freeDraft ?? "";
|
|
244
255
|
state.cursorIndex = state.draftText.length;
|
|
245
|
-
this.
|
|
246
|
-
this.tui.requestRender();
|
|
256
|
+
this.rerender();
|
|
247
257
|
return;
|
|
248
258
|
}
|
|
249
259
|
|
|
@@ -288,15 +298,13 @@ export class AskUserComponent implements Component {
|
|
|
288
298
|
// Esc → 回退到最后一个问题
|
|
289
299
|
if (matchesKey(data, "escape")) {
|
|
290
300
|
this.activeTab = this.questions.length - 1;
|
|
291
|
-
this.
|
|
292
|
-
this.tui.requestRender();
|
|
301
|
+
this.rerender();
|
|
293
302
|
return;
|
|
294
303
|
}
|
|
295
304
|
// Tab → Submit ↔ Cancel 循环切焦点(单键双向)
|
|
296
305
|
if (matchesKey(data, "tab")) {
|
|
297
306
|
this.submitTabFocus = this.submitTabFocus === "submit" ? "cancel" : "submit";
|
|
298
|
-
this.
|
|
299
|
-
this.tui.requestRender();
|
|
307
|
+
this.rerender();
|
|
300
308
|
return;
|
|
301
309
|
}
|
|
302
310
|
// Enter → 触发当前 focus
|
|
@@ -318,7 +326,7 @@ export class AskUserComponent implements Component {
|
|
|
318
326
|
this.handleEditorKey(data, keyId, state, q);
|
|
319
327
|
return;
|
|
320
328
|
}
|
|
321
|
-
|
|
329
|
+
if (handleEditorPaste(state, data)) this.rerender();
|
|
322
330
|
}
|
|
323
331
|
|
|
324
332
|
/** parseKey 命中的键:escape/enter/backspace/光标移动/space/printable 各有语义,
|
|
@@ -333,18 +341,24 @@ export class AskUserComponent implements Component {
|
|
|
333
341
|
return;
|
|
334
342
|
}
|
|
335
343
|
if (matchesKey(data, "backspace")) {
|
|
336
|
-
|
|
344
|
+
if (deleteCharBeforeCursor(state)) this.rerender();
|
|
337
345
|
return;
|
|
338
346
|
}
|
|
339
|
-
|
|
347
|
+
// 光标移动(4 方向 + home/end)
|
|
348
|
+
if (matchesKey(data, "left")) { moveCursorLeft(state); this.rerender(); return; }
|
|
349
|
+
if (matchesKey(data, "right")) { moveCursorRight(state); this.rerender(); return; }
|
|
350
|
+
if (matchesKey(data, "home")) { moveCursorHome(state); this.rerender(); return; }
|
|
351
|
+
if (matchesKey(data, "end")) { moveCursorEnd(state); this.rerender(); return; }
|
|
340
352
|
// 空格特判:parseKey(" ") 返回 "space"(非单字符),需显式插入
|
|
341
353
|
if (matchesKey(data, "space")) {
|
|
342
|
-
|
|
354
|
+
insertAtCursor(state, " ");
|
|
355
|
+
this.rerender();
|
|
343
356
|
return;
|
|
344
357
|
}
|
|
345
358
|
// 单字符 printable:parseKey("a") 返回 "a"(code 32-126),在光标处插入
|
|
346
359
|
if (keyId.length === 1 && keyId >= " " && keyId <= "~") {
|
|
347
|
-
|
|
360
|
+
insertAtCursor(state, keyId);
|
|
361
|
+
this.rerender();
|
|
348
362
|
return;
|
|
349
363
|
}
|
|
350
364
|
// 其他 special key(功能键/modifier 组合)→ no-op(不泄漏)
|
|
@@ -366,8 +380,7 @@ export class AskUserComponent implements Component {
|
|
|
366
380
|
state.mode = "options";
|
|
367
381
|
state.draftText = "";
|
|
368
382
|
state.cursorIndex = state.savedOptionsCursorIndex;
|
|
369
|
-
this.
|
|
370
|
-
this.tui.requestRender();
|
|
383
|
+
this.rerender();
|
|
371
384
|
}
|
|
372
385
|
|
|
373
386
|
/** Enter:freeform 有文本→提交,空文本→回退;comment→保存评论并 advance。 */
|
|
@@ -389,8 +402,7 @@ export class AskUserComponent implements Component {
|
|
|
389
402
|
if (q.multiSelect ? state.selectedIndices.size === 0 : state.selectedIndex === null) {
|
|
390
403
|
state.confirmed = false;
|
|
391
404
|
}
|
|
392
|
-
this.
|
|
393
|
-
this.tui.requestRender();
|
|
405
|
+
this.rerender();
|
|
394
406
|
}
|
|
395
407
|
return;
|
|
396
408
|
}
|
|
@@ -401,79 +413,13 @@ export class AskUserComponent implements Component {
|
|
|
401
413
|
this.advance();
|
|
402
414
|
}
|
|
403
415
|
|
|
404
|
-
/** 在光标处插入文本,光标前移 text.length。 */
|
|
405
|
-
private insertAtCursor(state: QuestionState, text: string): void {
|
|
406
|
-
state.draftText = state.draftText.slice(0, state.cursorIndex) + text + state.draftText.slice(state.cursorIndex);
|
|
407
|
-
state.cursorIndex += text.length;
|
|
408
|
-
this.invalidate();
|
|
409
|
-
this.tui.requestRender();
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/** 删除光标前一个 code point(surrogate pair 时删整个 code point)。 */
|
|
413
|
-
private deleteCharBeforeCursor(state: QuestionState): void {
|
|
414
|
-
if (state.cursorIndex <= 0) return;
|
|
415
|
-
const deleteCount = state.cursorIndex >= SURROGATE_PAIR_LEN && isHighSurrogate(state.draftText, state.cursorIndex - SURROGATE_PAIR_LEN) ? SURROGATE_PAIR_LEN : 1;
|
|
416
|
-
state.draftText = state.draftText.slice(0, state.cursorIndex - deleteCount) + state.draftText.slice(state.cursorIndex);
|
|
417
|
-
state.cursorIndex -= deleteCount;
|
|
418
|
-
this.invalidate();
|
|
419
|
-
this.tui.requestRender();
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/** 光标移动(left/right/home/end),surrogate pair 安全跳过代理中间位。
|
|
423
|
-
* 返回 true 表示命中了移动键;false 表示非移动键,交由上层处理。 */
|
|
424
|
-
private moveCursor(data: string, state: QuestionState): boolean {
|
|
425
|
-
if (matchesKey(data, "left")) {
|
|
426
|
-
const newLeft = state.cursorIndex - 1;
|
|
427
|
-
state.cursorIndex = newLeft > 0 && isHighSurrogate(state.draftText, newLeft - 1)
|
|
428
|
-
? newLeft - 1
|
|
429
|
-
: Math.max(0, newLeft);
|
|
430
|
-
} else if (matchesKey(data, "right")) {
|
|
431
|
-
state.cursorIndex = isHighSurrogate(state.draftText, state.cursorIndex)
|
|
432
|
-
? Math.min(state.draftText.length, state.cursorIndex + SURROGATE_PAIR_LEN)
|
|
433
|
-
: Math.min(state.draftText.length, state.cursorIndex + 1);
|
|
434
|
-
} else if (matchesKey(data, "home")) {
|
|
435
|
-
state.cursorIndex = 0;
|
|
436
|
-
} else if (matchesKey(data, "end")) {
|
|
437
|
-
state.cursorIndex = state.draftText.length;
|
|
438
|
-
} else {
|
|
439
|
-
return false;
|
|
440
|
-
}
|
|
441
|
-
this.invalidate();
|
|
442
|
-
this.tui.requestRender();
|
|
443
|
-
return true;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
/** 多字符粘贴 chunk → printable 提取(BC-1/BC-2/BC-3 保持)。
|
|
447
|
-
* 未识别控制序列(OSC/DA/DCS/APC/unknown CSI)整体丢弃,不提取可见残渣。
|
|
448
|
-
* 排除 bracketed paste 标记防 C-PASTE 退化。依赖 StdinBuffer 序列拆分保证 data 整体性。 */
|
|
449
|
-
private handleEditorPaste(data: string, state: QuestionState): void {
|
|
450
|
-
if (data.startsWith("\x1b") && !data.includes("\x1b[200~") && !data.includes("\x1b[201~")) {
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
const cleaned = data.replace(/\x1b\[200~|\x1b\[201~/g, "");
|
|
454
|
-
let changed = false;
|
|
455
|
-
// 用 Array.from 正确拆分 emoji/surrogate pairs
|
|
456
|
-
for (const c of Array.from(cleaned)) {
|
|
457
|
-
if (c >= " ") {
|
|
458
|
-
state.draftText = state.draftText.slice(0, state.cursorIndex) + c + state.draftText.slice(state.cursorIndex);
|
|
459
|
-
state.cursorIndex += c.length;
|
|
460
|
-
changed = true;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
if (changed) {
|
|
464
|
-
this.invalidate();
|
|
465
|
-
this.tui.requestRender();
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
416
|
private toggleIndex(state: QuestionState, index: number): void {
|
|
470
417
|
if (state.selectedIndices.has(index)) state.selectedIndices.delete(index);
|
|
471
418
|
else state.selectedIndices.add(index);
|
|
472
419
|
if (state.selectedIndices.size === 0 && state.freeTextValue === null) {
|
|
473
420
|
state.confirmed = false;
|
|
474
421
|
}
|
|
475
|
-
this.
|
|
476
|
-
this.tui.requestRender();
|
|
422
|
+
this.rerender();
|
|
477
423
|
}
|
|
478
424
|
|
|
479
425
|
private autoConfirmIfAnswered(): void {
|
|
@@ -490,21 +436,18 @@ export class AskUserComponent implements Component {
|
|
|
490
436
|
private gotoTab(target: number): void {
|
|
491
437
|
this.autoConfirmIfAnswered();
|
|
492
438
|
this.activeTab = target;
|
|
493
|
-
this.
|
|
494
|
-
this.tui.requestRender();
|
|
439
|
+
this.rerender();
|
|
495
440
|
}
|
|
496
441
|
|
|
497
442
|
/** Esc 语义:有上一个 tab 则回退;已在首个(或单问题)则进入确认取消覆盖层。 */
|
|
498
443
|
private escBackOrConfirm(): void {
|
|
499
444
|
if (this.activeTab > 0) {
|
|
500
445
|
this.activeTab--;
|
|
501
|
-
this.
|
|
502
|
-
this.tui.requestRender();
|
|
446
|
+
this.rerender();
|
|
503
447
|
return;
|
|
504
448
|
}
|
|
505
449
|
this.pendingCancel = true;
|
|
506
|
-
this.
|
|
507
|
-
this.tui.requestRender();
|
|
450
|
+
this.rerender();
|
|
508
451
|
}
|
|
509
452
|
|
|
510
453
|
/** 选中确认后的处理:若 allowComment,进入评论模式(可重入编辑/清除已有评论);否则前进。 */
|
|
@@ -515,8 +458,7 @@ export class AskUserComponent implements Component {
|
|
|
515
458
|
state.mode = "comment";
|
|
516
459
|
state.draftText = state.commentValue ?? "";
|
|
517
460
|
state.cursorIndex = state.draftText.length;
|
|
518
|
-
this.
|
|
519
|
-
this.tui.requestRender();
|
|
461
|
+
this.rerender();
|
|
520
462
|
return;
|
|
521
463
|
}
|
|
522
464
|
this.advance();
|
|
@@ -532,8 +474,7 @@ export class AskUserComponent implements Component {
|
|
|
532
474
|
} else {
|
|
533
475
|
this.activeTab = this.questions.length;
|
|
534
476
|
}
|
|
535
|
-
this.
|
|
536
|
-
this.tui.requestRender();
|
|
477
|
+
this.rerender();
|
|
537
478
|
}
|
|
538
479
|
|
|
539
480
|
private submit(): void {
|