gentle-pi 3.3.0 → 3.5.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.
Files changed (88) hide show
  1. package/README.md +88 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/bin/gentle-shell.mjs +198 -0
  4. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  5. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  6. package/docs/assets/features/agents-view.png +0 -0
  7. package/docs/assets/features/changes-view.png +0 -0
  8. package/docs/assets/features/command-palette.png +0 -0
  9. package/docs/assets/features/profiles-routing.png +0 -0
  10. package/docs/gentle-agents-activity.md +95 -0
  11. package/docs/gentle-shell.md +26 -2
  12. package/docs/readme-reference.md +99 -6
  13. package/extensions/ask-user-choice.ts +70 -22
  14. package/extensions/ask-user-question.ts +338 -0
  15. package/extensions/gentle-agents.ts +41 -1
  16. package/extensions/gentle-ai.ts +59 -18
  17. package/extensions/gentle-shell.ts +99 -10
  18. package/extensions/quiet-tools.ts +28 -5
  19. package/extensions/startup-banner.ts +25 -10
  20. package/lib/agents-rpc-publisher.ts +342 -0
  21. package/lib/agents-runner.ts +7 -2
  22. package/lib/animation-policy.ts +52 -0
  23. package/lib/background-cache-warming.ts +38 -0
  24. package/lib/command-palette-catalog.ts +1 -0
  25. package/lib/gentle-shell-launcher.ts +482 -0
  26. package/lib/inprocess-reviewer.ts +38 -1
  27. package/lib/native-review-cli.ts +36 -10
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +10 -0
  34. package/lib/review-integration-v2.ts +4 -1
  35. package/lib/rpc-host.ts +36 -0
  36. package/lib/shell-bar.ts +13 -0
  37. package/lib/shell-sidebar-layout.ts +10 -4
  38. package/lib/shell-usage-view.ts +5 -2
  39. package/lib/shell-usage.ts +120 -6
  40. package/package.json +5 -1
  41. package/runtime/gentle-shell-launcher.mjs +483 -0
  42. package/runtime/native-review-cli.mjs +35 -9
  43. package/runtime/review-integration-v2.mjs +4 -1
  44. package/scripts/build-runtime-modules.mjs +1 -0
  45. package/scripts/gentle-ai-installer.mjs +10 -10
  46. package/scripts/install-gentle-ai.mjs +14 -7
  47. package/scripts/install-tui-mode-setting.mjs +78 -1
  48. package/scripts/verify-package-files.mjs +6 -3
  49. package/tests/agents-rpc-publisher.test.ts +407 -0
  50. package/tests/agents-runner.test.ts +10 -0
  51. package/tests/animation-policy.test.ts +42 -0
  52. package/tests/ask-user-choice.test.ts +129 -0
  53. package/tests/ask-user-question.test.ts +661 -0
  54. package/tests/background-cache-warming.test.ts +60 -0
  55. package/tests/background-subagents.test.ts +68 -0
  56. package/tests/command-palette.test.ts +9 -0
  57. package/tests/gentle-agents.test.ts +161 -2
  58. package/tests/gentle-ai-binary.test.ts +1 -1
  59. package/tests/gentle-ai-installer.test.ts +47 -47
  60. package/tests/gentle-ai.test.ts +56 -4
  61. package/tests/gentle-shell-bin.test.ts +188 -0
  62. package/tests/gentle-shell-launcher.test.ts +718 -0
  63. package/tests/gentle-shell.test.ts +355 -2
  64. package/tests/inprocess-reviewer.test.ts +92 -0
  65. package/tests/install-tui-mode-guard.test.ts +99 -0
  66. package/tests/install-tui-mode-setting.test.ts +39 -1
  67. package/tests/native-review-capability-contract.test.ts +16 -1
  68. package/tests/native-review-parity.test.ts +19 -0
  69. package/tests/package-manifest.test.ts +6 -17
  70. package/tests/questionnaire-schema.test.ts +274 -0
  71. package/tests/questionnaire-view.test.ts +446 -0
  72. package/tests/rdd-status-line.test.ts +21 -4
  73. package/tests/review-candidate-owner-retry.test.ts +63 -0
  74. package/tests/review-candidate-view.test.ts +15 -0
  75. package/tests/review-controller-native-routing.test.ts +86 -0
  76. package/tests/review-host-relay.test.ts +21 -0
  77. package/tests/review-integration-v2.test.ts +30 -0
  78. package/tests/review-ledger-contract.test.ts +1 -2
  79. package/tests/review-relay-transport-agent.test.ts +107 -2
  80. package/tests/review-risk-assessment.test.ts +104 -0
  81. package/tests/rpc-host.test.ts +77 -0
  82. package/tests/shell-bar.test.ts +8 -0
  83. package/tests/shell-sidebar-layout.test.ts +60 -5
  84. package/tests/shell-usage.test.ts +129 -0
  85. package/tests/skill-collision-prefixes.test.ts +1 -1
  86. package/tests/startup-banner.test.ts +93 -2
  87. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  88. package/skills/release/SKILL.md +0 -137
@@ -0,0 +1,603 @@
1
+ import {
2
+ Container,
3
+ Input,
4
+ isKeyRelease,
5
+ matchesKey,
6
+ Text,
7
+ visibleWidth,
8
+ type Focusable,
9
+ type KeybindingsManager,
10
+ type TuiMouseEvent,
11
+ } from "@earendil-works/pi-tui";
12
+ import { CUSTOM_ROW_LABEL, type QuestionData } from "./schema.ts";
13
+
14
+ /** Minimum terminal width at which the preview pane splits beside the list. */
15
+ export const MIN_PREVIEW_WIDTH = 80;
16
+
17
+ /** Fraction of the width given to the option column when a preview pane is shown. */
18
+ const PREVIEW_SPLIT = 0.45;
19
+
20
+ /** Two-space gutter between the option column and the preview pane. No divider frame. */
21
+ const PREVIEW_GAP = " ";
22
+
23
+ /** Theme surface used by the questionnaire, compatible with the Pi TUI theme. */
24
+ export interface QuestionnaireTheme {
25
+ fg(color: string, text: string): string;
26
+ bg?(color: string, text: string): string;
27
+ bold?(text: string): string;
28
+ }
29
+
30
+ /** One committed answer for a question. */
31
+ export interface AnswerRow {
32
+ questionIndex: number;
33
+ question: string;
34
+ kind: "option" | "custom" | "multi";
35
+ answer: string | null;
36
+ selected?: string[];
37
+ preview?: string;
38
+ }
39
+
40
+ /** Final questionnaire outcome handed to the caller. */
41
+ export interface QuestionnaireResult {
42
+ cancelled: boolean;
43
+ answers: AnswerRow[];
44
+ }
45
+
46
+ /** Construction options for {@link QuestionnaireView}. */
47
+ export interface QuestionnaireViewOptions {
48
+ questions: QuestionData[];
49
+ theme: QuestionnaireTheme;
50
+ keybindings?: KeybindingsManager;
51
+ onComplete?: (result: QuestionnaireResult) => void;
52
+ }
53
+
54
+ interface QuestionState {
55
+ cursor: number;
56
+ toggled: Set<number>;
57
+ answer: AnswerRow | undefined;
58
+ customDraft: string;
59
+ }
60
+
61
+ interface LineOwner {
62
+ questionIndex: number;
63
+ rowIndex: number;
64
+ }
65
+
66
+ /** Small inline text editor for the free-text row; owns an {@link Input}. */
67
+ class CustomTextEditor extends Container {
68
+ private readonly input = new Input({ prompt: "> ", placeholder: "Type your response" });
69
+ private readonly keybindings: KeybindingsManager | undefined;
70
+ private readonly onSubmit: (value: string) => void;
71
+ private readonly onCancel: () => void;
72
+
73
+ constructor(
74
+ keybindings: KeybindingsManager | undefined,
75
+ onSubmit: (value: string) => void,
76
+ onCancel: () => void,
77
+ ) {
78
+ super();
79
+ this.keybindings = keybindings;
80
+ this.onSubmit = onSubmit;
81
+ this.onCancel = onCancel;
82
+ this.input.focused = false;
83
+ this.addChild(new Text("Custom response", 1, 0));
84
+ this.addChild(this.input);
85
+ this.addChild(new Text("Enter to submit • Esc to return to choices", 1, 0));
86
+ }
87
+
88
+ setFocused(focused: boolean): void {
89
+ this.input.focused = focused;
90
+ }
91
+
92
+ getValue(): string {
93
+ return this.input.getValue();
94
+ }
95
+
96
+ setValue(value: string): void {
97
+ this.input.setValue(value);
98
+ // Place the caret at the end of a restored draft so typing appends to it.
99
+ this.input.handleInput("\x1b[F");
100
+ this.invalidate();
101
+ }
102
+
103
+ handleInput(data: string): void {
104
+ if (isKeyRelease(data)) return;
105
+ if (this.matches(data, "tui.select.cancel")) {
106
+ this.onCancel();
107
+ return;
108
+ }
109
+ if (this.matches(data, "tui.input.submit")) {
110
+ this.onSubmit(this.input.getValue());
111
+ return;
112
+ }
113
+ this.input.handleInput(data);
114
+ this.invalidate();
115
+ }
116
+
117
+ private matches(data: string, binding: "tui.select.cancel" | "tui.input.submit"): boolean {
118
+ if (this.keybindings?.matches) return this.keybindings.matches(data, binding);
119
+ const key = binding === "tui.select.cancel" ? "escape" : "enter";
120
+ return matchesKey(data, key);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * One-question-at-a-time questionnaire.
126
+ *
127
+ * The whole questionnaire is represented as a compact tab strip: exactly one
128
+ * question body is rendered at a time, and Tab/Shift-Tab switches the active
129
+ * question while each question keeps its own cursor, toggles, and custom-text
130
+ * draft. This keeps the component's height bounded for one to four questions
131
+ * so it fits the native dock area instead of overflowing the viewport.
132
+ *
133
+ * Native dock-swap component: it is a {@link Container}, never an overlay, so
134
+ * the transcript stays scrollable while it is focused. Keyboard handling uses
135
+ * the public Pi TUI input protocol (`matchesKey()` and the injected
136
+ * `KeybindingsManager`), matching how the shipped agent views read input.
137
+ */
138
+ export class QuestionnaireView extends Container implements Focusable {
139
+ private readonly questions: QuestionData[];
140
+ private readonly theme: QuestionnaireTheme;
141
+ private readonly keybindings: KeybindingsManager | undefined;
142
+ private readonly onComplete: ((result: QuestionnaireResult) => void) | undefined;
143
+ private readonly states: QuestionState[];
144
+ private readonly editor: CustomTextEditor;
145
+ private focusedQuestion = 0;
146
+ private editingQuestion: number | undefined;
147
+ private completed = false;
148
+ private result: QuestionnaireResult | undefined;
149
+ private lineOwners: Array<LineOwner | undefined> = [];
150
+ private _focused = false;
151
+
152
+ constructor(options: QuestionnaireViewOptions) {
153
+ super();
154
+ this.questions = options.questions;
155
+ this.theme = options.theme;
156
+ this.keybindings = options.keybindings;
157
+ this.onComplete = options.onComplete;
158
+ this.states = options.questions.map(() => ({
159
+ cursor: 0,
160
+ toggled: new Set<number>(),
161
+ answer: undefined,
162
+ customDraft: "",
163
+ }));
164
+ this.editor = new CustomTextEditor(
165
+ options.keybindings,
166
+ (value) => this.submitCustom(value),
167
+ () => this.closeEditor(),
168
+ );
169
+ }
170
+
171
+ /** Focusable: propagate focus so the free-text input gets the IME cursor. */
172
+ get focused(): boolean {
173
+ return this._focused;
174
+ }
175
+
176
+ set focused(value: boolean) {
177
+ this._focused = value;
178
+ this.editor.setFocused(value);
179
+ this.invalidate();
180
+ }
181
+
182
+ /** Current committed result. Safe to call before completion. */
183
+ getResult(): QuestionnaireResult {
184
+ return this.result ?? { cancelled: false, answers: this.collectedAnswers() };
185
+ }
186
+
187
+ /** Active question index; exposed for tests and for callers that drive the view. */
188
+ get activeQuestion(): number {
189
+ return this.focusedQuestion;
190
+ }
191
+
192
+ handleInput(data: string): void {
193
+ if (this.completed || isKeyRelease(data)) return;
194
+
195
+ if (this.editingQuestion !== undefined) {
196
+ // Tab still switches questions while editing; the draft is preserved.
197
+ if (this.matchesTab(data, false)) {
198
+ this.closeEditor();
199
+ this.moveFocus(1);
200
+ return;
201
+ }
202
+ if (this.matchesTab(data, true)) {
203
+ this.closeEditor();
204
+ this.moveFocus(-1);
205
+ return;
206
+ }
207
+ this.editor.handleInput(data);
208
+ return;
209
+ }
210
+
211
+ if (this.matches(data, "tui.select.cancel")) {
212
+ this.finish({ cancelled: true, answers: this.collectedAnswers() });
213
+ return;
214
+ }
215
+
216
+ if (this.matchesTab(data, false)) {
217
+ this.moveFocus(1);
218
+ return;
219
+ }
220
+
221
+ if (this.matchesTab(data, true)) {
222
+ this.moveFocus(-1);
223
+ return;
224
+ }
225
+
226
+ if (this.matches(data, "tui.select.up")) {
227
+ this.moveCursor(-1);
228
+ return;
229
+ }
230
+
231
+ if (this.matches(data, "tui.select.down")) {
232
+ this.moveCursor(1);
233
+ return;
234
+ }
235
+
236
+ if (matchesKey(data, "space")) {
237
+ const question = this.questions[this.focusedQuestion];
238
+ if (question?.multiSelect) {
239
+ this.toggleCursor();
240
+ return;
241
+ }
242
+ }
243
+
244
+ if (this.matches(data, "tui.select.confirm")) {
245
+ this.commit();
246
+ }
247
+ }
248
+
249
+ override handleMouse(event: TuiMouseEvent) {
250
+ if (this.completed) return undefined;
251
+ if (this.editingQuestion !== undefined) return this.editor.handleMouse(event);
252
+
253
+ const owner = this.lineOwners[event.y];
254
+ if (!owner || owner.rowIndex < 0 || event.button !== "left") return undefined;
255
+
256
+ if (event.type === "press") {
257
+ const changed = this.focusRow(owner.questionIndex, owner.rowIndex);
258
+ return { handled: true as const, focus: true, render: changed, target: this.mouseTarget(event) };
259
+ }
260
+
261
+ if (event.type === "click") {
262
+ const question = this.questions[owner.questionIndex];
263
+ this.focusRow(owner.questionIndex, owner.rowIndex);
264
+ // A multiSelect option toggles in place; only single-select (or the
265
+ // custom row, which opens the editor) commits on click.
266
+ if (question?.multiSelect && owner.rowIndex < question.options.length) {
267
+ this.toggleCursor();
268
+ }
269
+ else {
270
+ this.commit();
271
+ }
272
+ return { handled: true as const, render: true, target: this.mouseTarget(event) };
273
+ }
274
+
275
+ return undefined;
276
+ }
277
+
278
+ override render(width: number): string[] {
279
+ const viewport = Math.max(1, width);
280
+ const lines: string[] = [];
281
+ const owners: Array<LineOwner | undefined> = [];
282
+ const push = (text: string, owner?: LineOwner) => {
283
+ for (const line of this.wrap(text, viewport)) {
284
+ lines.push(line);
285
+ owners.push(owner);
286
+ }
287
+ };
288
+
289
+ if (this.questions.length === 0) {
290
+ this.lineOwners = [];
291
+ return [];
292
+ }
293
+
294
+ push(this.renderTabs());
295
+ push("");
296
+
297
+ const preview = this.currentPreview();
298
+ if (preview !== undefined && viewport >= MIN_PREVIEW_WIDTH) {
299
+ const leftWidth = Math.max(1, Math.floor(viewport * PREVIEW_SPLIT));
300
+ const rightWidth = Math.max(1, viewport - leftWidth - PREVIEW_GAP.length);
301
+ const left = this.renderBody(leftWidth, false);
302
+ const right = this.wrap(this.theme.fg("dim", preview), rightWidth);
303
+ const rows = Math.max(left.lines.length, right.length);
304
+ for (let index = 0; index < rows; index++) {
305
+ lines.push(`${padTo(left.lines[index] ?? "", leftWidth)}${PREVIEW_GAP}${right[index] ?? ""}`);
306
+ owners.push(left.owners[index]);
307
+ }
308
+ }
309
+ else {
310
+ const body = this.renderBody(viewport, preview !== undefined);
311
+ lines.push(...body.lines);
312
+ owners.push(...body.owners);
313
+ }
314
+
315
+ push("");
316
+ push(this.hint());
317
+
318
+ this.lineOwners = owners;
319
+ return lines;
320
+ }
321
+
322
+ override invalidate(): void {
323
+ this.lineOwners = [];
324
+ super.invalidate();
325
+ this.editor.setFocused(this._focused);
326
+ }
327
+
328
+ /** Compact tab strip: every question is a chip, exactly one is active. */
329
+ private renderTabs(): string {
330
+ const total = this.questions.length;
331
+ const progress = this.theme.fg("dim", `[${this.focusedQuestion + 1}/${total}]`);
332
+ const chips = this.questions.map((question, index) => {
333
+ const answered = this.states[index]?.answer !== undefined;
334
+ const label = `${answered ? "✓ " : ""}${question.header}`;
335
+ return index === this.focusedQuestion
336
+ ? this.accent(`▸ ${label}`)
337
+ : this.theme.fg("muted", ` ${label}`);
338
+ });
339
+ return `${progress} ${chips.join(" ")}`;
340
+ }
341
+
342
+ /** Body for the active question only. */
343
+ private renderBody(width: number, inlinePreview: boolean): { lines: string[]; owners: Array<LineOwner | undefined> } {
344
+ const lines: string[] = [];
345
+ const owners: Array<LineOwner | undefined> = [];
346
+ const push = (text: string, owner?: LineOwner) => {
347
+ for (const line of this.wrap(text, width)) {
348
+ lines.push(line);
349
+ owners.push(owner);
350
+ }
351
+ };
352
+
353
+ const question = this.questions[this.focusedQuestion];
354
+ const state = this.states[this.focusedQuestion];
355
+ if (!question || !state) return { lines, owners };
356
+
357
+ const headerOwner: LineOwner = { questionIndex: this.focusedQuestion, rowIndex: -1 };
358
+ push(this.accent(question.question), headerOwner);
359
+
360
+ if (this.editingQuestion === this.focusedQuestion) {
361
+ for (const line of this.editor.render(width)) {
362
+ lines.push(line);
363
+ owners.push(headerOwner);
364
+ }
365
+ return { lines, owners };
366
+ }
367
+
368
+ const customIndex = question.options.length;
369
+ for (const [optionIndex, option] of question.options.entries()) {
370
+ const owner: LineOwner = { questionIndex: this.focusedQuestion, rowIndex: optionIndex };
371
+ const cursor = state.cursor === optionIndex ? this.accent("❯ ") : " ";
372
+ const marker = question.multiSelect ? `${state.toggled.has(optionIndex) ? "[x]" : "[ ]"} ` : "";
373
+ push(`${cursor}${marker}${option.label}`, owner);
374
+ push(` ${this.theme.fg("dim", option.description)}`, owner);
375
+ if (inlinePreview && state.cursor === optionIndex && option.preview !== undefined) {
376
+ for (const line of this.wrap(this.theme.fg("dim", option.preview), Math.max(1, width - 4))) {
377
+ push(` ${line}`, owner);
378
+ }
379
+ }
380
+ }
381
+
382
+ const customOwner: LineOwner = { questionIndex: this.focusedQuestion, rowIndex: customIndex };
383
+ const customCursor = state.cursor === customIndex ? this.accent("❯ ") : " ";
384
+ const customDone = state.answer?.kind === "custom" ? "✓ " : "";
385
+ push(`${customCursor}${customDone}${CUSTOM_ROW_LABEL}`, customOwner);
386
+
387
+ return { lines, owners };
388
+ }
389
+
390
+ /** Bottom hint for the active question's interaction model. */
391
+ private hint(): string {
392
+ const question = this.questions[this.focusedQuestion];
393
+ const parts = ["↑↓ move"];
394
+ if (question?.multiSelect) parts.push("space toggle");
395
+ parts.push("enter select", "tab switch", "esc cancel");
396
+ return this.theme.fg("dim", parts.join(" · "));
397
+ }
398
+
399
+ private wrap(text: string, width: number): string[] {
400
+ return new Text(text, 0, 0).render(Math.max(1, width));
401
+ }
402
+
403
+ private accent(text: string): string {
404
+ const bold = this.theme.bold ? this.theme.bold(text) : text;
405
+ return this.theme.fg("accent", bold);
406
+ }
407
+
408
+ private currentPreview(): string | undefined {
409
+ if (this.completed || this.editingQuestion !== undefined) return undefined;
410
+ const question = this.questions[this.focusedQuestion];
411
+ const state = this.states[this.focusedQuestion];
412
+ if (!question || !state) return undefined;
413
+ if (state.cursor < 0 || state.cursor >= question.options.length) return undefined;
414
+ return question.options[state.cursor]?.preview;
415
+ }
416
+
417
+ private moveFocus(delta: number): void {
418
+ const total = this.questions.length;
419
+ if (total === 0) return;
420
+ this.focusedQuestion = (this.focusedQuestion + delta + total) % total;
421
+ this.invalidate();
422
+ }
423
+
424
+ private moveCursor(delta: number): void {
425
+ const question = this.questions[this.focusedQuestion];
426
+ const state = this.states[this.focusedQuestion];
427
+ if (!question || !state) return;
428
+ const total = question.options.length + 1;
429
+ state.cursor = Math.max(0, Math.min(total - 1, state.cursor + delta));
430
+ this.invalidate();
431
+ }
432
+
433
+ private toggleCursor(): void {
434
+ const question = this.questions[this.focusedQuestion];
435
+ const state = this.states[this.focusedQuestion];
436
+ if (!question || !state) return;
437
+ if (state.cursor === question.options.length) {
438
+ this.openEditor(this.focusedQuestion);
439
+ return;
440
+ }
441
+ if (state.toggled.has(state.cursor)) state.toggled.delete(state.cursor);
442
+ else state.toggled.add(state.cursor);
443
+ this.invalidate();
444
+ }
445
+
446
+ private focusRow(questionIndex: number, rowIndex: number): boolean {
447
+ const question = this.questions[questionIndex];
448
+ const state = this.states[questionIndex];
449
+ if (!question || !state) return false;
450
+ const changed = this.focusedQuestion !== questionIndex || state.cursor !== rowIndex;
451
+ this.focusedQuestion = questionIndex;
452
+ state.cursor = Math.max(0, Math.min(question.options.length, rowIndex));
453
+ this.invalidate();
454
+ return changed;
455
+ }
456
+
457
+ private commit(): void {
458
+ const question = this.questions[this.focusedQuestion];
459
+ const state = this.states[this.focusedQuestion];
460
+ if (!question || !state) return;
461
+ const customIndex = question.options.length;
462
+
463
+ if (state.cursor === customIndex) {
464
+ this.openEditor(this.focusedQuestion);
465
+ return;
466
+ }
467
+
468
+ if (question.multiSelect) {
469
+ const toggled = [...state.toggled]
470
+ .filter((index) => index < customIndex)
471
+ .sort((a, b) => a - b);
472
+ if (toggled.length === 0) return;
473
+ state.answer = {
474
+ questionIndex: this.focusedQuestion,
475
+ question: question.question,
476
+ kind: "multi",
477
+ answer: null,
478
+ selected: toggled.map((index) => question.options[index]!.label),
479
+ };
480
+ this.afterCommit(this.focusedQuestion);
481
+ return;
482
+ }
483
+
484
+ const option = question.options[state.cursor];
485
+ if (!option) return;
486
+ state.answer = {
487
+ questionIndex: this.focusedQuestion,
488
+ question: question.question,
489
+ kind: "option",
490
+ answer: option.label,
491
+ ...(option.preview !== undefined ? { preview: option.preview } : {}),
492
+ };
493
+ this.afterCommit(this.focusedQuestion);
494
+ }
495
+
496
+ private openEditor(questionIndex: number): void {
497
+ const state = this.states[questionIndex];
498
+ if (!state) return;
499
+ this.editingQuestion = questionIndex;
500
+ this.editor.setValue(state.customDraft);
501
+ this.editor.setFocused(this._focused);
502
+ this.invalidate();
503
+ }
504
+
505
+ private closeEditor(): void {
506
+ if (this.editingQuestion === undefined) return;
507
+ const state = this.states[this.editingQuestion];
508
+ if (state) state.customDraft = this.editor.getValue();
509
+ this.editingQuestion = undefined;
510
+ this.editor.setValue("");
511
+ this.editor.setFocused(false);
512
+ this.invalidate();
513
+ }
514
+
515
+ private submitCustom(value: string): void {
516
+ const questionIndex = this.editingQuestion;
517
+ if (questionIndex === undefined) return;
518
+ const question = this.questions[questionIndex];
519
+ const state = this.states[questionIndex];
520
+ if (!question || !state) {
521
+ this.closeEditor();
522
+ return;
523
+ }
524
+ if (value.trim().length === 0) {
525
+ // Whitespace-only is treated as empty: discard it so reopening is clean.
526
+ this.editor.setValue("");
527
+ this.closeEditor();
528
+ return;
529
+ }
530
+ const customIndex = question.options.length;
531
+ const selected = [...state.toggled]
532
+ .filter((index) => index < customIndex)
533
+ .sort((a, b) => a - b)
534
+ .map((index) => question.options[index]!.label);
535
+ state.customDraft = value;
536
+ state.answer = {
537
+ questionIndex,
538
+ question: question.question,
539
+ kind: "custom",
540
+ answer: value,
541
+ ...(question.multiSelect && selected.length > 0 ? { selected } : {}),
542
+ };
543
+ this.closeEditor();
544
+ this.afterCommit(questionIndex);
545
+ }
546
+
547
+ private afterCommit(questionIndex: number): void {
548
+ if (this.states.every((state) => state.answer !== undefined)) {
549
+ this.finish({ cancelled: false, answers: this.collectedAnswers() });
550
+ return;
551
+ }
552
+ // Advance to the first unanswered question so a commit is visible and the
553
+ // tab strip keeps moving; committed answers stay reachable with Tab.
554
+ const next = this.states.findIndex((state) => state.answer === undefined);
555
+ if (next !== -1 && next !== questionIndex) this.focusedQuestion = next;
556
+ this.invalidate();
557
+ }
558
+
559
+ private collectedAnswers(): AnswerRow[] {
560
+ return this.states
561
+ .map((state) => state.answer)
562
+ .filter((answer): answer is AnswerRow => answer !== undefined);
563
+ }
564
+
565
+ private finish(result: QuestionnaireResult): void {
566
+ if (this.completed) return;
567
+ this.completed = true;
568
+ this.result = result;
569
+ this.onComplete?.(result);
570
+ this.invalidate();
571
+ }
572
+
573
+ private matches(
574
+ data: string,
575
+ binding: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel",
576
+ ): boolean {
577
+ if (this.keybindings?.matches) return this.keybindings.matches(data, binding);
578
+ const key = binding === "tui.select.up" ? "up"
579
+ : binding === "tui.select.down" ? "down"
580
+ : binding === "tui.select.confirm" ? "enter" : "escape";
581
+ return matchesKey(data, key);
582
+ }
583
+
584
+ private matchesTab(data: string, shift: boolean): boolean {
585
+ if (!shift && this.keybindings?.matches) return this.keybindings.matches(data, "tui.input.tab");
586
+ return matchesKey(data, shift ? "shift+tab" : "tab");
587
+ }
588
+
589
+ private mouseTarget(event: TuiMouseEvent) {
590
+ return {
591
+ component: this,
592
+ originX: event.screenX - event.x,
593
+ originY: event.screenY - event.y,
594
+ width: event.width,
595
+ height: event.height,
596
+ };
597
+ }
598
+ }
599
+
600
+ function padTo(line: string, width: number): string {
601
+ const padding = width - visibleWidth(line);
602
+ return padding > 0 ? `${line}${" ".repeat(padding)}` : line;
603
+ }
@@ -0,0 +1,82 @@
1
+ import { type Static, Type } from "typebox";
2
+
3
+ /** Maximum number of questions accepted by one questionnaire. */
4
+ export const MAX_QUESTIONS = 4;
5
+
6
+ /** Minimum number of authored options per question. */
7
+ export const MIN_OPTIONS = 2;
8
+
9
+ /** Maximum number of authored options per question. */
10
+ export const MAX_OPTIONS = 4;
11
+
12
+ /** Maximum length of a question header chip. */
13
+ export const MAX_HEADER_LENGTH = 16;
14
+
15
+ /** Maximum length of an authored option label. */
16
+ export const MAX_LABEL_LENGTH = 60;
17
+
18
+ /**
19
+ * Label of the free-text row that the view always appends after the authored
20
+ * options. Authors must not create an option that collides with it.
21
+ */
22
+ export const CUSTOM_ROW_LABEL = "Type something.";
23
+
24
+ const OptionSchema = Type.Object(
25
+ {
26
+ label: Type.String({
27
+ maxLength: MAX_LABEL_LENGTH,
28
+ description: "Short user-facing option label",
29
+ }),
30
+ description: Type.String({
31
+ description: "One-line explanation shown under the label",
32
+ }),
33
+ preview: Type.Optional(Type.String({
34
+ description: "Optional markdown-flavored preview shown beside the focused option",
35
+ })),
36
+ },
37
+ { additionalProperties: false },
38
+ );
39
+
40
+ const QuestionSchema = Type.Object(
41
+ {
42
+ question: Type.String({ description: "The full question text" }),
43
+ header: Type.String({
44
+ maxLength: MAX_HEADER_LENGTH,
45
+ description: "Short progress header, at most 16 characters",
46
+ }),
47
+ options: Type.Array(OptionSchema, {
48
+ minItems: MIN_OPTIONS,
49
+ maxItems: MAX_OPTIONS,
50
+ description: "Two to four ordered options",
51
+ }),
52
+ multiSelect: Type.Optional(Type.Boolean({
53
+ default: false,
54
+ description: "Allow selecting more than one option for this question",
55
+ })),
56
+ },
57
+ { additionalProperties: false },
58
+ );
59
+
60
+ /**
61
+ * Typebox parameters for the native `ask_user_question` tool: one to four
62
+ * questions rendered together as a single questionnaire.
63
+ */
64
+ export const QuestionParamsSchema = Type.Object(
65
+ {
66
+ questions: Type.Array(QuestionSchema, {
67
+ minItems: 1,
68
+ maxItems: MAX_QUESTIONS,
69
+ description: "One to four questions rendered as a single questionnaire",
70
+ }),
71
+ },
72
+ { additionalProperties: false },
73
+ );
74
+
75
+ /** One authored option as received from the tool call. */
76
+ export type OptionData = Static<typeof OptionSchema>;
77
+
78
+ /** One authored question as received from the tool call. */
79
+ export type QuestionData = Static<typeof QuestionSchema>;
80
+
81
+ /** Validated tool parameters for the questionnaire. */
82
+ export type QuestionParams = Static<typeof QuestionParamsSchema>;