@zhushanwen/pi-ask-user 0.0.4 → 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/ARCHITECTURE.md +171 -0
- package/README.md +135 -9
- package/package.json +3 -2
- package/src/__tests__/component-keymap.test.ts +553 -0
- package/src/__tests__/component.test.ts +50 -12
- package/src/__tests__/fixtures.ts +56 -2
- package/src/__tests__/index.test.ts +24 -1
- package/src/__tests__/question-view.test.ts +21 -18
- package/src/__tests__/sdk-contract.test.ts +111 -0
- package/src/__tests__/validate.test.ts +14 -1
- package/src/__tests__/w2-draft-hint.test.ts +246 -0
- package/src/__tests__/w3-regression.test.ts +310 -0
- package/src/component.ts +159 -97
- package/src/index.ts +6 -4
- package/src/question-view.ts +64 -33
- package/src/submit-view.ts +17 -7
- package/src/types.ts +24 -2
- package/src/validate.ts +9 -1
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
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,80 +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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
358
|
+
state.draftText = "";
|
|
359
|
+
state.cursorIndex = state.savedOptionsCursorIndex;
|
|
376
360
|
this.advance();
|
|
377
361
|
return;
|
|
378
362
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
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~")) {
|
|
383
451
|
return;
|
|
384
452
|
}
|
|
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
|
-
// 先剥离 bracketed paste 标记序列:启用该模式的终端会把粘贴内容包裹在
|
|
390
|
-
// \x1b[200~ ... \x1b[201~ 中。下面的 `c >= " "` 守卫会滤掉 ESC(\x1b),
|
|
391
|
-
// 但序列里的可见字符([200~/[201~)会残留混进编辑器文本,必须显式剥离。
|
|
392
453
|
const cleaned = data.replace(/\x1b\[200~|\x1b\[201~/g, "");
|
|
393
|
-
// for...of 按 code point 迭代:代理对(如 😀 U+1F600)作为一个 c(length===2)出现,
|
|
394
|
-
// 因此不能用 `c.length === 1` 守卫——那会把所有 BMP 之外的字符(emoji、部分 CJK 扩展)全过滤。
|
|
395
|
-
// 只保留 `c >= " "`(code point 比较),过滤控制字符(< 空格 U+0020)。
|
|
396
454
|
let changed = false;
|
|
397
|
-
|
|
455
|
+
// 用 Array.from 正确拆分 emoji/surrogate pairs
|
|
456
|
+
for (const c of Array.from(cleaned)) {
|
|
398
457
|
if (c >= " ") {
|
|
399
|
-
|
|
458
|
+
state.draftText = state.draftText.slice(0, state.cursorIndex) + c + state.draftText.slice(state.cursorIndex);
|
|
459
|
+
state.cursorIndex += c.length;
|
|
400
460
|
changed = true;
|
|
401
461
|
}
|
|
402
462
|
}
|
|
403
463
|
if (changed) {
|
|
404
464
|
this.invalidate();
|
|
405
465
|
this.tui.requestRender();
|
|
406
|
-
return;
|
|
407
466
|
}
|
|
408
467
|
}
|
|
409
468
|
|
|
@@ -452,10 +511,10 @@ export class AskUserComponent implements Component {
|
|
|
452
511
|
private afterConfirm(state: QuestionState, q: Question): void {
|
|
453
512
|
state.confirmed = true;
|
|
454
513
|
if (q.allowComment && state.mode !== "comment") {
|
|
455
|
-
|
|
456
|
-
// 允许回改时重新编辑/清除已输入评论,避免评论被错误附着到后改的答案。
|
|
514
|
+
state.savedOptionsCursorIndex = state.cursorIndex;
|
|
457
515
|
state.mode = "comment";
|
|
458
|
-
|
|
516
|
+
state.draftText = state.commentValue ?? "";
|
|
517
|
+
state.cursorIndex = state.draftText.length;
|
|
459
518
|
this.invalidate();
|
|
460
519
|
this.tui.requestRender();
|
|
461
520
|
return;
|
|
@@ -482,8 +541,11 @@ export class AskUserComponent implements Component {
|
|
|
482
541
|
this.done(buildResult(this.questions, this.states));
|
|
483
542
|
}
|
|
484
543
|
|
|
485
|
-
/** 取消。public 供 signal abort 监听器复用 _resolved 守卫(FR-12
|
|
544
|
+
/** 取消。public 供 signal abort 监听器复用 _resolved 守卫(FR-12 竞态)。
|
|
545
|
+
* 守卫在方法内:signal abort 可能在用户已 submit/cancel 后才触发,
|
|
546
|
+
* 此时必须 no-op,避免二次调 done(FR-12)。 */
|
|
486
547
|
cancel(): void {
|
|
548
|
+
if (this._resolved) return;
|
|
487
549
|
this._resolved = true;
|
|
488
550
|
this.done(null);
|
|
489
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:
|
|
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: "
|
|
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,
|
package/src/question-view.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
isHighSurrogate,
|
|
5
6
|
OTHER_LABEL,
|
|
6
7
|
type Question,
|
|
7
8
|
type QuestionState,
|
|
@@ -9,9 +10,16 @@ import {
|
|
|
9
10
|
SPLIT_PANE_MIN_WIDTH,
|
|
10
11
|
SPLIT_PANE_RIGHT_MIN,
|
|
11
12
|
SPLIT_PANE_SEPARATOR,
|
|
13
|
+
SURROGATE_PAIR_LEN,
|
|
12
14
|
type ThemeLike,
|
|
13
15
|
} from "./types";
|
|
14
16
|
|
|
17
|
+
const SPLIT_PANE_LEFT_RATIO = 0.42;
|
|
18
|
+
const DESCRIPTION_INDENT_MULTI = 10;
|
|
19
|
+
const DESCRIPTION_INDENT_SINGLE = 8;
|
|
20
|
+
const PREVIEW_MIN_WIDTH = 10;
|
|
21
|
+
const QUESTION_TEXT_MARGIN = 2;
|
|
22
|
+
|
|
15
23
|
export interface DisplayOption {
|
|
16
24
|
label: string;
|
|
17
25
|
description?: string;
|
|
@@ -21,6 +29,21 @@ export interface DisplayOption {
|
|
|
21
29
|
/** Other 自由输入 / 已保存预览的软换行行数上限。超出则截断并加省略号。 */
|
|
22
30
|
const MAX_EDITOR_LINES = 5;
|
|
23
31
|
|
|
32
|
+
/**
|
|
33
|
+
* 渲染编辑器文本,光标位置用反色高亮(ANSI SGR 7/27),不占额外列。
|
|
34
|
+
* surrogate pair 安全:光标在高代理前时反色高亮整个 code point(2 个 code unit),
|
|
35
|
+
* 与光标移动/Backspace 的跳过逻辑对称,避免拆散 emoji 导致终端显示替换字符。
|
|
36
|
+
* 光标在文本末尾(超出范围)时反色高亮一个空格占位。
|
|
37
|
+
*/
|
|
38
|
+
function renderCursorText(text: string, cursorPos: number): string {
|
|
39
|
+
const before = text.slice(0, cursorPos);
|
|
40
|
+
// 光标在高代理前 → 反色高亮整个 surrogate pair
|
|
41
|
+
const charLen = isHighSurrogate(text, cursorPos) ? SURROGATE_PAIR_LEN : 1;
|
|
42
|
+
const charAtCursor = text.slice(cursorPos, cursorPos + charLen) || " ";
|
|
43
|
+
const after = text.slice(cursorPos + charLen);
|
|
44
|
+
return `${before}\x1b[7m${charAtCursor}\x1b[27m${after}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
/**
|
|
25
48
|
* 把一段带样式的文本按 availWidth 软换行输出为多行,最多 maxLines 行。
|
|
26
49
|
* - 首行前缀 lead(如 "> [ ] "),后续行用等宽空格缩进到 input 起始列对齐。
|
|
@@ -29,7 +52,7 @@ const MAX_EDITOR_LINES = 5;
|
|
|
29
52
|
*
|
|
30
53
|
* @param push 输出回调(通常为带 truncateToWidth 的 add,提供安全兜底)
|
|
31
54
|
* @param lead 首行前缀(含选中标记 / 勾选框)
|
|
32
|
-
* @param content 待换行展示的已样式化文本(可含 ANSI +
|
|
55
|
+
* @param content 待换行展示的已样式化文本(可含 ANSI + 末尾反色光标),为空则只输出 lead
|
|
33
56
|
* @param availWidth 单行可用宽度
|
|
34
57
|
* @param maxLines 最多行数
|
|
35
58
|
*/
|
|
@@ -71,7 +94,7 @@ export function getSplitPaneWidths(width: number): { left: number; right: number
|
|
|
71
94
|
if (width < SPLIT_PANE_MIN_WIDTH) return null;
|
|
72
95
|
const available = width - SPLIT_PANE_SEPARATOR.length;
|
|
73
96
|
if (available < SPLIT_PANE_LEFT_MIN + SPLIT_PANE_RIGHT_MIN) return null;
|
|
74
|
-
const preferredLeft = Math.floor(available *
|
|
97
|
+
const preferredLeft = Math.floor(available * SPLIT_PANE_LEFT_RATIO);
|
|
75
98
|
const left = Math.max(
|
|
76
99
|
SPLIT_PANE_LEFT_MIN,
|
|
77
100
|
Math.min(preferredLeft, available - SPLIT_PANE_RIGHT_MIN),
|
|
@@ -81,8 +104,12 @@ export function getSplitPaneWidths(width: number): { left: number; right: number
|
|
|
81
104
|
return { left, right };
|
|
82
105
|
}
|
|
83
106
|
|
|
107
|
+
/** 编辑器底部操作提示:随实现能力更新(实现偏差 D-005 已支持光标移动)。
|
|
108
|
+
* 反色光标占位已暗示可输入,故突出新增的方向键/移动能力。 */
|
|
109
|
+
const EDITOR_HINT = " ←/→ Home/End move · Backspace deletes · Enter submit · Esc back";
|
|
110
|
+
|
|
84
111
|
/** 构建选项列表行(不含分屏预览)。hideDescriptions 用于分屏模式左列。
|
|
85
|
-
* freeform 模式下,Other 行**原地**变 [ ] <input
|
|
112
|
+
* freeform 模式下,Other 行**原地**变 [ ] <input> 反色光标(多选)/ <input> 反色光标(单选),
|
|
86
113
|
* 不再依赖 buildEditorBlock 的下方独立编辑块。 */
|
|
87
114
|
function buildOptionLines(
|
|
88
115
|
q: Question,
|
|
@@ -90,7 +117,7 @@ function buildOptionLines(
|
|
|
90
117
|
theme: ThemeLike,
|
|
91
118
|
width: number,
|
|
92
119
|
hideDescriptions: boolean,
|
|
93
|
-
|
|
120
|
+
draftText: string = "",
|
|
94
121
|
): string[] {
|
|
95
122
|
const t = theme;
|
|
96
123
|
const opts = allOptions(q);
|
|
@@ -101,7 +128,9 @@ function buildOptionLines(
|
|
|
101
128
|
|
|
102
129
|
for (let i = 0; i < opts.length; i++) {
|
|
103
130
|
const opt = opts[i]!;
|
|
104
|
-
|
|
131
|
+
// 编辑器模式下用 savedOptionsCursorIndex 判断选项高亮,cursorIndex 此时是文本光标
|
|
132
|
+
const activeOptionCursor = (state.mode === "freeform" || state.mode === "comment") ? state.savedOptionsCursorIndex : state.cursorIndex;
|
|
133
|
+
const isSelected = i === activeOptionCursor;
|
|
105
134
|
const isOther = opt.isOther === true;
|
|
106
135
|
const prefix = isSelected ? t.fg("accent", ">") : " ";
|
|
107
136
|
|
|
@@ -115,15 +144,16 @@ function buildOptionLines(
|
|
|
115
144
|
const num = i + 1;
|
|
116
145
|
const lead = `${prefix} ${marker} `;
|
|
117
146
|
const avail = Math.max(1, width - visibleWidth(lead));
|
|
118
|
-
// 编号 +
|
|
119
|
-
const
|
|
147
|
+
// 编号 + 文本,光标用反色高亮当前字符(surrogate pair 安全,不占额外位置)
|
|
148
|
+
const cursorText = renderCursorText(draftText, state.cursorIndex);
|
|
149
|
+
const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", cursorText)}`;
|
|
120
150
|
addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
|
|
121
151
|
} else {
|
|
122
152
|
const hasFreeText = state.freeTextValue !== null;
|
|
123
153
|
const marker = q.multiSelect
|
|
124
154
|
? (hasFreeText ? t.fg("success", "[✓]") : t.fg("dim", "[ ]"))
|
|
125
155
|
: (hasFreeText ? t.fg("success", "✓") : " ");
|
|
126
|
-
const labelColor = isSelected ? "accent" : "
|
|
156
|
+
const labelColor = isSelected ? "accent" : "text";
|
|
127
157
|
const num = i + 1;
|
|
128
158
|
add(`${prefix} ${marker} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
129
159
|
if (hasFreeText) {
|
|
@@ -145,7 +175,7 @@ function buildOptionLines(
|
|
|
145
175
|
const num = i + 1;
|
|
146
176
|
add(`${prefix} ${box} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
147
177
|
if (opt.description && !hideDescriptions) {
|
|
148
|
-
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width -
|
|
178
|
+
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_MULTI);
|
|
149
179
|
for (const line of wrapped) add(` ${line}`);
|
|
150
180
|
}
|
|
151
181
|
} else {
|
|
@@ -155,7 +185,7 @@ function buildOptionLines(
|
|
|
155
185
|
const num = i + 1;
|
|
156
186
|
add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
157
187
|
if (opt.description && !hideDescriptions) {
|
|
158
|
-
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width -
|
|
188
|
+
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_SINGLE);
|
|
159
189
|
for (const line of wrapped) add(` ${line}`);
|
|
160
190
|
}
|
|
161
191
|
}
|
|
@@ -184,19 +214,14 @@ function buildPreviewLines(
|
|
|
184
214
|
if (opt.description?.trim()) text += `\n\n${opt.description}`;
|
|
185
215
|
}
|
|
186
216
|
|
|
187
|
-
const wrapped = wrapTextWithAnsi(t.fg("muted", text), Math.max(
|
|
217
|
+
const wrapped = wrapTextWithAnsi(t.fg("muted", text), Math.max(PREVIEW_MIN_WIDTH, width));
|
|
188
218
|
const lines = wrapped.slice(0, maxLines);
|
|
189
219
|
if (wrapped.length > maxLines) lines.push(t.fg("dim", "…"));
|
|
190
220
|
return lines;
|
|
191
221
|
}
|
|
192
222
|
|
|
193
223
|
/**
|
|
194
|
-
*
|
|
195
|
-
* isSingle: 单问题模式(无 Tab 提示)。
|
|
196
|
-
* editorText: freeform/comment 模式下当前编辑器文本(纯 string,由 component 持有)。
|
|
197
|
-
*/
|
|
198
|
-
/**
|
|
199
|
-
* freeform 模式:editor 已在 buildOptionLines 中原地渲染([ ] <input>█ 行),
|
|
224
|
+
* freeform 模式:editor 已在 buildOptionLines 中原地渲染([ ] <input> 反色光标 行),
|
|
200
225
|
* buildEditorBlock 在此模式下不重复输出,**仅留出与正常 help 行同位置的视觉空隙**。
|
|
201
226
|
* comment 模式:保留独立编辑块(与 normal help 行解耦:comment 行有更长的 prompt)。
|
|
202
227
|
*/
|
|
@@ -204,7 +229,8 @@ function buildEditorBlock(
|
|
|
204
229
|
theme: ThemeLike,
|
|
205
230
|
width: number,
|
|
206
231
|
mode: "freeform" | "comment",
|
|
207
|
-
|
|
232
|
+
draftText: string,
|
|
233
|
+
cursorIndex?: number,
|
|
208
234
|
): string[] {
|
|
209
235
|
if (mode === "freeform") {
|
|
210
236
|
return [""];
|
|
@@ -217,12 +243,12 @@ function buildEditorBlock(
|
|
|
217
243
|
add("");
|
|
218
244
|
const prompt = t.fg("muted", " Your comment (optional):");
|
|
219
245
|
add(prompt);
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
add(` ${t.fg("
|
|
246
|
+
// 渲染当前编辑器文本,光标用反色高亮当前字符(surrogate pair 安全)
|
|
247
|
+
const pos = cursorIndex ?? draftText.length;
|
|
248
|
+
const cursorText = renderCursorText(draftText, pos);
|
|
249
|
+
add(` ${t.fg("text", cursorText)}`);
|
|
224
250
|
add("");
|
|
225
|
-
add(t.fg("dim",
|
|
251
|
+
add(t.fg("dim", EDITOR_HINT));
|
|
226
252
|
return lines;
|
|
227
253
|
}
|
|
228
254
|
|
|
@@ -233,14 +259,14 @@ function buildSplitPane(
|
|
|
233
259
|
theme: ThemeLike,
|
|
234
260
|
split: { left: number; right: number },
|
|
235
261
|
width: number,
|
|
236
|
-
|
|
262
|
+
draftText: string = "",
|
|
237
263
|
): string[] {
|
|
238
264
|
const t = theme;
|
|
239
265
|
const lines: string[] = [];
|
|
240
266
|
const add = (s: string): void => {
|
|
241
267
|
lines.push(truncateToWidth(s, width));
|
|
242
268
|
};
|
|
243
|
-
const leftLines = buildOptionLines(q, state, theme, split.left, true,
|
|
269
|
+
const leftLines = buildOptionLines(q, state, theme, split.left, true, draftText);
|
|
244
270
|
const rightLines = buildPreviewLines(q, state, theme, split.right, Math.max(leftLines.length, 8));
|
|
245
271
|
const rowCount = Math.max(leftLines.length, rightLines.length);
|
|
246
272
|
const sep = t.fg("dim", SPLIT_PANE_SEPARATOR);
|
|
@@ -252,13 +278,18 @@ function buildSplitPane(
|
|
|
252
278
|
return lines;
|
|
253
279
|
}
|
|
254
280
|
|
|
281
|
+
/**
|
|
282
|
+
* 渲染单个问题视图(spec FR-4)。
|
|
283
|
+
* isSingle: 单问题模式(无 Tab 提示)。
|
|
284
|
+
* draftText: freeform/comment 模式下当前编辑器草稿(来自 QuestionState.draftText)。
|
|
285
|
+
*/
|
|
255
286
|
export function renderQuestionView(
|
|
256
287
|
q: Question,
|
|
257
288
|
state: QuestionState,
|
|
258
289
|
theme: ThemeLike,
|
|
259
290
|
width: number,
|
|
260
291
|
isSingle: boolean,
|
|
261
|
-
|
|
292
|
+
draftText: string,
|
|
262
293
|
): string[] {
|
|
263
294
|
const t = theme;
|
|
264
295
|
const lines: string[] = [];
|
|
@@ -268,13 +299,13 @@ export function renderQuestionView(
|
|
|
268
299
|
const divider = (): void => add(t.fg("dim", "─".repeat(Math.max(0, width))));
|
|
269
300
|
|
|
270
301
|
// 问题文本(word-wrap)
|
|
271
|
-
const wrapped = wrapTextWithAnsi(t.fg("text", ` ${q.question}`), width -
|
|
302
|
+
const wrapped = wrapTextWithAnsi(t.fg("text", ` ${q.question}`), width - QUESTION_TEXT_MARGIN);
|
|
272
303
|
for (const line of wrapped) add(line);
|
|
273
304
|
|
|
274
305
|
// 上下文(如有)
|
|
275
306
|
if (q.context?.trim()) {
|
|
276
307
|
divider();
|
|
277
|
-
const ctxWrapped = wrapTextWithAnsi(t.fg("muted", q.context), width -
|
|
308
|
+
const ctxWrapped = wrapTextWithAnsi(t.fg("muted", q.context), width - QUESTION_TEXT_MARGIN);
|
|
278
309
|
for (const line of ctxWrapped) add(line);
|
|
279
310
|
}
|
|
280
311
|
|
|
@@ -292,24 +323,24 @@ export function renderQuestionView(
|
|
|
292
323
|
// 且右侧详情预览在输入自定义内容时无意义。隐藏 descriptions 以避免行数爆炸。
|
|
293
324
|
if (state.mode === "freeform" || state.mode === "comment") {
|
|
294
325
|
add("");
|
|
295
|
-
const optionLines = buildOptionLines(q, state, theme, width, false,
|
|
326
|
+
const optionLines = buildOptionLines(q, state, theme, width, false, draftText);
|
|
296
327
|
for (const line of optionLines) add(line);
|
|
297
|
-
const editorBlock = buildEditorBlock(theme, width, state.mode,
|
|
328
|
+
const editorBlock = buildEditorBlock(theme, width, state.mode, draftText, state.cursorIndex);
|
|
298
329
|
lines.push(...editorBlock);
|
|
299
330
|
if (state.mode === "freeform") {
|
|
300
331
|
// freeform 模式 help 行:光标锁在 Other 上,正在输入
|
|
301
|
-
add(t.fg("dim",
|
|
332
|
+
add(t.fg("dim", EDITOR_HINT));
|
|
302
333
|
}
|
|
303
334
|
return lines;
|
|
304
335
|
}
|
|
305
336
|
|
|
306
337
|
if (!split) {
|
|
307
338
|
// 单列模式
|
|
308
|
-
const optionLines = buildOptionLines(q, state, theme, width, false,
|
|
339
|
+
const optionLines = buildOptionLines(q, state, theme, width, false, draftText);
|
|
309
340
|
for (const line of optionLines) add(line);
|
|
310
341
|
} else {
|
|
311
342
|
// 分屏模式
|
|
312
|
-
lines.push(...buildSplitPane(q, state, theme, split, width,
|
|
343
|
+
lines.push(...buildSplitPane(q, state, theme, split, width, draftText));
|
|
313
344
|
}
|
|
314
345
|
|
|
315
346
|
add("");
|