@zhushanwen/pi-ask-user 0.0.3 → 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/src/component.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  // src/component.ts
2
- import { type Component, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
2
+ import { type Component, matchesKey, parseKey, truncateToWidth } from "@mariozechner/pi-tui";
3
3
 
4
4
  import { allOptions, renderQuestionView } from "./question-view";
5
- import { buildResult, renderSubmitView } from "./submit-view";
5
+ import { buildResult, renderButtonBar, renderSubmitView } from "./submit-view";
6
6
  import {
7
7
  createQuestionState,
8
8
  HEADER_MAX_CHARS,
9
+ isHighSurrogate,
9
10
  type Question,
10
11
  type QuestionState,
11
12
  type Result,
13
+ SURROGATE_PAIR_LEN,
12
14
  type ThemeLike,
13
15
  } from "./types";
14
16
 
@@ -32,7 +34,6 @@ export class AskUserComponent implements Component {
32
34
 
33
35
  private states: QuestionState[];
34
36
  private activeTab: number = 0;
35
- private editorText: string = "";
36
37
 
37
38
  /** Submit tab 上的左右焦点:默认 Submit;← / → 切换;Enter 触发当前项。
38
39
  * 问题 tab 上无意义(仅视觉占位),不参与输入路由。 */
@@ -107,7 +108,7 @@ export class AskUserComponent implements Component {
107
108
  } else {
108
109
  const q = this.questions[this.activeTab]!;
109
110
  const state = this.states[this.activeTab]!;
110
- for (const line of renderQuestionView(q, state, t, innerWidth, this.isSingle, this.editorText)) {
111
+ for (const line of renderQuestionView(q, state, t, innerWidth, this.isSingle, state.draftText)) {
111
112
  add(line);
112
113
  }
113
114
  }
@@ -115,7 +116,7 @@ export class AskUserComponent implements Component {
115
116
  // 多问题:底部按钮栏(Submit / Cancel)。Submit tab 上不重复渲染(renderSubmitView 内嵌 focus 高亮)
116
117
  if (!this.isSingle && this.activeTab < this.questions.length) {
117
118
  inner.push("");
118
- this.renderButtonBar(innerWidth, add, null);
119
+ add(renderButtonBar(this.theme, this.allConfirmed(), null));
119
120
  }
120
121
 
121
122
  // 用 box 边框包裹:每行 pad 到 innerWidth 后加 │ 左右边框
@@ -162,28 +163,6 @@ export class AskUserComponent implements Component {
162
163
  add(truncateToWidth(parts.join(""), innerWidth));
163
164
  }
164
165
 
165
- /**
166
- * 底部按钮栏:[ Submit ] [ Cancel ]。
167
- * focus: null=纯展示(问题 tab),"submit"/"cancel"=高亮对应按钮(Submit tab)。
168
- */
169
- private renderButtonBar(
170
- innerWidth: number,
171
- add: (s: string) => void,
172
- focus: "submit" | "cancel" | null,
173
- ): void {
174
- const t = this.theme;
175
- const allDone = this.allConfirmed();
176
- const isSubmit = focus === "submit";
177
- const isCancel = focus === "cancel";
178
- const submit = isSubmit
179
- ? (allDone ? t.fg("success", t.bold(" Submit ")) : t.fg("accent", t.bold(" Submit ")))
180
- : (allDone ? t.fg("success", " Submit ") : t.fg("dim", " Submit "));
181
- const cancel = isCancel
182
- ? t.fg("accent", t.bold(" Cancel "))
183
- : t.fg("muted", " Cancel ");
184
- add(`${t.fg("dim", "[")}${submit}${t.fg("dim", "]")} ${t.fg("dim", "[")}${cancel}${t.fg("dim", "]")}`);
185
- }
186
-
187
166
  // ── 输入路由 ──
188
167
  handleInput(data: string): void {
189
168
  if (this._resolved) return;
@@ -215,15 +194,22 @@ export class AskUserComponent implements Component {
215
194
  return;
216
195
  }
217
196
 
197
+ // options mode → delegate to handleOptionsInput
198
+ this.handleOptionsInput(data, state, q);
199
+ }
200
+
201
+ /**
202
+ * options 模式的输入处理(从 handleInput 拆出)。
203
+ * 含:Esc 回退/确认取消、←/→ 切 tab、↑/↓ 移光标、Enter 确认、Space toggle。
204
+ */
205
+ private handleOptionsInput(data: string, state: QuestionState, q: Question): void {
218
206
  // Esc → 回退到上一个问题;在首个问题时进入确认取消覆盖层
219
207
  if (matchesKey(data, "escape")) {
220
208
  this.escBackOrConfirm();
221
209
  return;
222
210
  }
223
211
 
224
- // ← / → 切换问题 tab(多问题,options 模式):末尾 → 进入 Submit tab,
225
- // 首个 ← 停在首个问题(不环绕)。Tab/Shift+Tab 不在问题 tab 切 tab——
226
- // 留给 Submit tab 上的 Submit/Cancel 焦点切换。
212
+ // ← / → 切换问题 tab(多问题,options 模式)
227
213
  if (!this.isSingle && matchesKey(data, "right")) {
228
214
  this.gotoTab(Math.min(this.activeTab + 1, this.questions.length));
229
215
  return;
@@ -250,12 +236,12 @@ export class AskUserComponent implements Component {
250
236
  const opts = allOptions(q);
251
237
  const onOther = state.cursorIndex === opts.length - 1;
252
238
 
253
- // Other row → Enter opens freeform editor(单选/多选统一入口)。
254
- // 单选/多选普通选项的 Enter 各自有不同语义(确认/加入选中),与 Other 互斥:
255
- // onOther 时本分支先消费 Enter,下面多选/单选分支不再处理。
239
+ // Other row → Enter opens freeform editor
256
240
  if (onOther && matchesKey(data, "enter")) {
241
+ state.savedOptionsCursorIndex = state.cursorIndex;
257
242
  state.mode = "freeform";
258
- this.editorText = state.freeTextValue ?? "";
243
+ state.draftText = state.freeTextValue ?? state.freeDraft ?? "";
244
+ state.cursorIndex = state.draftText.length;
259
245
  this.invalidate();
260
246
  this.tui.requestRender();
261
247
  return;
@@ -267,10 +253,6 @@ export class AskUserComponent implements Component {
267
253
  return;
268
254
  }
269
255
  if (matchesKey(data, "enter")) {
270
- // Enter 先把光标所在的普通选项加入选中再确认,与单选分支(Enter 时
271
- // selectedIndex = cursorIndex)保持一致。即使 freeTextValue 已存在
272
- // (Other 录入),也尊重「在此选项上按 Enter 想选中它」的意图,避免
273
- // 静默用旧 Other 文本确认。add 幂等,不会误取消已选项。
274
256
  state.selectedIndices.add(state.cursorIndex);
275
257
  this.afterConfirm(state, q);
276
258
  return;
@@ -330,76 +312,157 @@ export class AskUserComponent implements Component {
330
312
  }
331
313
 
332
314
  private handleEditorInput(data: string, state: QuestionState, q: Question): void {
315
+ // parseKey 白名单拦截 — 替代旧的 matchesKey 散调 + 兜底 printable 遍历
316
+ const keyId = parseKey(data);
317
+ if (keyId !== undefined) {
318
+ this.handleEditorKey(data, keyId, state, q);
319
+ return;
320
+ }
321
+ this.handleEditorPaste(data, state);
322
+ }
323
+
324
+ /** parseKey 命中的键:escape/enter/backspace/光标移动/space/printable 各有语义,
325
+ * 其他 special key(功能键/modifier 组合)no-op。 */
326
+ private handleEditorKey(data: string, keyId: string, state: QuestionState, q: Question): void {
333
327
  if (matchesKey(data, "escape")) {
334
- if (state.mode === "comment") {
335
- // AC-17: Esc in comment = skip comment, advance (keep existing commentValue)
336
- state.mode = "options";
337
- this.editorText = "";
338
- this.advance();
339
- return;
340
- }
341
- // Esc in freeform editor = back to options (discard input)
342
- state.mode = "options";
343
- this.editorText = "";
344
- this.invalidate();
345
- this.tui.requestRender();
328
+ this.handleEditorEsc(state);
346
329
  return;
347
330
  }
348
331
  if (matchesKey(data, "enter")) {
349
- const text = this.editorText.trim();
350
- if (state.mode === "freeform") {
351
- if (text) {
352
- state.freeTextValue = text;
353
- state.selectedIndex = null;
354
- state.mode = "options";
355
- this.editorText = "";
356
- // freeform 保存后走 afterConfirm(可能进 comment 模式)
357
- this.afterConfirm(state, q);
358
- } else {
359
- // FR-6: 空 Enter 仅清除 freeTextValue、关闭编辑器回选项列表,不含确认语义(不置 confirmed)
360
- state.freeTextValue = null;
361
- state.mode = "options";
362
- this.editorText = "";
363
- // 对齐 toggleIndex 守卫:清空后若全无答案,重置 confirmed,维持 confirmed ⟹ 有答案 不变式
364
- if ((q.multiSelect ? state.selectedIndices.size === 0 : state.selectedIndex === null) && state.freeTextValue === null) {
365
- state.confirmed = false;
366
- }
367
- this.invalidate();
368
- this.tui.requestRender();
369
- }
370
- return;
371
- }
372
- // comment mode:保存评论后直接前进(不再回头进 comment)
373
- state.commentValue = text || null;
332
+ this.handleEditorEnter(state, q);
333
+ return;
334
+ }
335
+ if (matchesKey(data, "backspace")) {
336
+ this.deleteCharBeforeCursor(state);
337
+ return;
338
+ }
339
+ if (this.moveCursor(data, state)) return;
340
+ // 空格特判:parseKey(" ") 返回 "space"(非单字符),需显式插入
341
+ if (matchesKey(data, "space")) {
342
+ this.insertAtCursor(state, " ");
343
+ return;
344
+ }
345
+ // 单字符 printable:parseKey("a") 返回 "a"(code 32-126),在光标处插入
346
+ if (keyId.length === 1 && keyId >= " " && keyId <= "~") {
347
+ this.insertAtCursor(state, keyId);
348
+ return;
349
+ }
350
+ // 其他 special key(功能键/modifier 组合)→ no-op(不泄漏)
351
+ }
352
+
353
+ /** Esc:comment 跳过评论并 advance;freeform 存 freeDraft 草稿后回 options。 */
354
+ private handleEditorEsc(state: QuestionState): void {
355
+ if (state.mode === "comment") {
356
+ // comment Esc: skip comment, advance (keep existing commentValue)
374
357
  state.mode = "options";
375
- this.editorText = "";
358
+ state.draftText = "";
359
+ state.cursorIndex = state.savedOptionsCursorIndex;
376
360
  this.advance();
377
361
  return;
378
362
  }
379
- if (matchesKey(data, "backspace")) {
380
- this.editorText = this.editorText.slice(0, -1);
381
- this.invalidate();
382
- this.tui.requestRender();
363
+ // freeform Esc: save draft to freeDraft (separate from submitted freeTextValue)
364
+ // so discarded drafts don't pollute the answer or trigger auto-confirm.
365
+ state.freeDraft = state.draftText || null;
366
+ state.mode = "options";
367
+ state.draftText = "";
368
+ state.cursorIndex = state.savedOptionsCursorIndex;
369
+ this.invalidate();
370
+ this.tui.requestRender();
371
+ }
372
+
373
+ /** Enter:freeform 有文本→提交,空文本→回退;comment→保存评论并 advance。 */
374
+ private handleEditorEnter(state: QuestionState, q: Question): void {
375
+ const text = state.draftText.trim();
376
+ if (state.mode === "freeform") {
377
+ state.cursorIndex = state.savedOptionsCursorIndex;
378
+ if (text) {
379
+ state.freeTextValue = text;
380
+ state.selectedIndex = null;
381
+ state.mode = "options";
382
+ state.draftText = "";
383
+ this.afterConfirm(state, q);
384
+ } else {
385
+ state.freeTextValue = null;
386
+ state.mode = "options";
387
+ state.draftText = "";
388
+ // freeTextValue 刚清空;confirmed 仅在无其他选择时置 false(允许重新作答)
389
+ if (q.multiSelect ? state.selectedIndices.size === 0 : state.selectedIndex === null) {
390
+ state.confirmed = false;
391
+ }
392
+ this.invalidate();
393
+ this.tui.requestRender();
394
+ }
383
395
  return;
384
396
  }
385
- // Printable char(s) — handle both single keystrokes and multi-char paste.
386
- // Terminals deliver pasted text as a single data chunk; iterating each char
387
- // ensures the full paste is captured instead of silently dropping everything
388
- // after the first character.
389
- // for...of 按 code point 迭代:代理对(如 😀 U+1F600)作为一个 c(length===2)出现,
390
- // 因此不能用 `c.length === 1` 守卫——那会把所有 BMP 之外的字符(emoji、部分 CJK 扩展)全过滤。
391
- // 只保留 `c >= " "`(code point 比较),过滤控制字符(< 空格 U+0020)。
397
+ state.commentValue = text || null;
398
+ state.mode = "options";
399
+ state.draftText = "";
400
+ state.cursorIndex = state.savedOptionsCursorIndex;
401
+ this.advance();
402
+ }
403
+
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, "");
392
454
  let changed = false;
393
- for (const c of data) {
455
+ // 用 Array.from 正确拆分 emoji/surrogate pairs
456
+ for (const c of Array.from(cleaned)) {
394
457
  if (c >= " ") {
395
- this.editorText += c;
458
+ state.draftText = state.draftText.slice(0, state.cursorIndex) + c + state.draftText.slice(state.cursorIndex);
459
+ state.cursorIndex += c.length;
396
460
  changed = true;
397
461
  }
398
462
  }
399
463
  if (changed) {
400
464
  this.invalidate();
401
465
  this.tui.requestRender();
402
- return;
403
466
  }
404
467
  }
405
468
 
@@ -448,10 +511,10 @@ export class AskUserComponent implements Component {
448
511
  private afterConfirm(state: QuestionState, q: Question): void {
449
512
  state.confirmed = true;
450
513
  if (q.allowComment && state.mode !== "comment") {
451
- // 进入评论输入行。预填已有评论,空 Enter=清除、新文本=覆盖(FR-4 item6)。
452
- // 允许回改时重新编辑/清除已输入评论,避免评论被错误附着到后改的答案。
514
+ state.savedOptionsCursorIndex = state.cursorIndex;
453
515
  state.mode = "comment";
454
- this.editorText = state.commentValue ?? "";
516
+ state.draftText = state.commentValue ?? "";
517
+ state.cursorIndex = state.draftText.length;
455
518
  this.invalidate();
456
519
  this.tui.requestRender();
457
520
  return;
@@ -478,8 +541,11 @@ export class AskUserComponent implements Component {
478
541
  this.done(buildResult(this.questions, this.states));
479
542
  }
480
543
 
481
- /** 取消。public 供 signal abort 监听器复用 _resolved 守卫(FR-12 竞态) */
544
+ /** 取消。public 供 signal abort 监听器复用 _resolved 守卫(FR-12 竞态)。
545
+ * 守卫在方法内:signal abort 可能在用户已 submit/cancel 后才触发,
546
+ * 此时必须 no-op,避免二次调 done(FR-12)。 */
482
547
  cancel(): void {
548
+ if (this._resolved) return;
483
549
  this._resolved = true;
484
550
  this.done(null);
485
551
  }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import type { AgentToolResult, ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
2
+ import type { AgentToolResult, AgentToolUpdateCallback, ExtensionAPI, ExtensionContext, ToolRenderResultOptions } from "@mariozechner/pi-coding-agent";
3
3
  import { Box, Text, TruncatedText, truncateToWidth } from "@mariozechner/pi-tui";
4
4
  import { type Static } from "@sinclair/typebox";
5
5
 
@@ -76,7 +76,7 @@ If you recommend an option, prefix its label with "(Recommended)" and list it fi
76
76
  _toolCallId: string,
77
77
  params: Static<typeof InputSchema>,
78
78
  signal: AbortSignal | undefined,
79
- _onUpdate: unknown,
79
+ _onUpdate: AgentToolUpdateCallback<AskUserDetails> | undefined,
80
80
  ctx: ExtensionContext,
81
81
  ): Promise<ExecuteResult> {
82
82
  const questions = params.questions;
@@ -111,13 +111,15 @@ If you recommend an option, prefix its label with "(Recommended)" and list it fi
111
111
  };
112
112
  }
113
113
 
114
- // 3. Signal abort 入口检查(spec FR-10)
114
+ // 3. Signal abort 入口检查(spec FR-10)。
115
+ // abort 是 agent 被外部终止(goal 取消 / context compact / session 切换),
116
+ // 不是用户意图——文案必须区别于 step 5 的用户取消,避免 LLM 俊等用户。
115
117
  if (signal?.aborted) {
116
118
  return {
117
119
  content: [
118
120
  {
119
121
  type: "text" as const,
120
- text: "User cancelled. Do not assume an answer or continue the task — wait for new instructions or re-ask with refined options if the decision is still required.",
122
+ text: "Agent aborted (goal cancelled, context compacted, or session switched). Do not assume an answer; do not retry ask_user — propagate the abort, or wait for new instructions if the decision is still required.",
121
123
  },
122
124
  ],
123
125
  details: { questions, answers: {}, cancelled: true } satisfies Result,