@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.
@@ -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, state.draftText)) {
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.invalidate();
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.invalidate();
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.invalidate();
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.invalidate();
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.invalidate();
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.invalidate();
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
- this.handleEditorPaste(data, state);
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
- this.deleteCharBeforeCursor(state);
344
+ if (deleteCharBeforeCursor(state)) this.rerender();
337
345
  return;
338
346
  }
339
- if (this.moveCursor(data, state)) return;
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
- this.insertAtCursor(state, " ");
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
- this.insertAtCursor(state, keyId);
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.invalidate();
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.invalidate();
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.invalidate();
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.invalidate();
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.invalidate();
502
- this.tui.requestRender();
446
+ this.rerender();
503
447
  return;
504
448
  }
505
449
  this.pendingCancel = true;
506
- this.invalidate();
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.invalidate();
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.invalidate();
536
- this.tui.requestRender();
477
+ this.rerender();
537
478
  }
538
479
 
539
480
  private submit(): void {