@fitchmultz/pi-ask-question 0.6.5 → 0.6.7

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,545 @@
1
+ /** Full-screen question form component with paging and cooperative answers. */
2
+
3
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ Key,
6
+ matchesKey,
7
+ type Component,
8
+ type Editor,
9
+ type Focusable,
10
+ type Keybinding,
11
+ type KeybindingsManager,
12
+ type OverlayHandle,
13
+ type TUI,
14
+ type TuiMouseEvent,
15
+ type TuiMouseEventResult,
16
+ } from "@earendil-works/pi-tui";
17
+ import { AnswerSheet } from "./form-answers.ts";
18
+ import {
19
+ buildFormPage,
20
+ ContextReader,
21
+ createPromptEditor,
22
+ reserveWidgetRows,
23
+ type FormView,
24
+ } from "./form-page.ts";
25
+ import { FormScrolls } from "./form-scrolls.ts";
26
+ import { clean, uniqueOptionLabel } from "./normalize.ts";
27
+ import { PromptChannel, type ChannelHost } from "./prompt-channel.ts";
28
+ import type { ValidatedRemoteAnswer } from "./remote-answers.ts";
29
+ import type { QuestionObserver } from "./question-observer.ts";
30
+ import {
31
+ CUSTOM_OPTION,
32
+ type Answer,
33
+ type NormalizedQuestion,
34
+ type QuestionResult,
35
+ type QuestionTimeout,
36
+ } from "./types.ts";
37
+
38
+ /** Wiring the form needs from the registering ask flow. */
39
+ export type QuestionFormDeps = {
40
+ questions: NormalizedQuestion[];
41
+ promptId: string;
42
+ ui: ExtensionContext["ui"];
43
+ pi: ExtensionAPI;
44
+ observer: QuestionObserver;
45
+ timeout: QuestionTimeout;
46
+ signal: AbortSignal | undefined;
47
+ onDone: (result: QuestionResult) => void;
48
+ };
49
+
50
+ /** The interactive question form shown by ui.custom in TUI mode. */
51
+ export class QuestionForm implements Component, Focusable, FormView, ChannelHost {
52
+ private readonly deps: QuestionFormDeps;
53
+ private readonly tui: TUI;
54
+ readonly theme: Theme;
55
+ private readonly keys: KeybindingsManager;
56
+ private readonly sheet = new AnswerSheet();
57
+ private readonly scrolls = new FormScrolls();
58
+ private readonly context = new ContextReader();
59
+ private readonly editor: Editor;
60
+ readonly submitTab: number;
61
+ readonly showTabs: boolean;
62
+ private readonly widgetKey: string;
63
+ private readonly unsubscribeInput: () => void;
64
+ private readonly unsubscribeShutdown: () => void;
65
+ private readonly channel: PromptChannel;
66
+ readonly abort = (): void => this.finish(true);
67
+
68
+ tab = 0;
69
+ option = 0;
70
+ editing = false;
71
+ finished = false;
72
+
73
+ private overlayHandle: OverlayHandle | undefined;
74
+ private reservedRows = 0;
75
+ private cachedWidth: number | undefined;
76
+ private cachedHeight: number | undefined;
77
+ private cachedLines: string[] | undefined;
78
+
79
+ constructor(deps: QuestionFormDeps, tui: TUI, theme: Theme, keys: KeybindingsManager) {
80
+ this.deps = deps;
81
+ this.tui = tui;
82
+ this.theme = theme;
83
+ this.keys = keys;
84
+ this.submitTab = deps.questions.length;
85
+ this.showTabs =
86
+ deps.questions.length > 1 || deps.questions.some((question) => question.multiSelect);
87
+ this.widgetKey = `ask-question.${deps.promptId}`;
88
+ this.editor = createPromptEditor(tui, theme);
89
+ this.editor.onSubmit = (value) => {
90
+ this.submitCustomAnswer(value);
91
+ };
92
+
93
+ reserveWidgetRows(deps.ui, this.widgetKey, () => this.reservedRows);
94
+
95
+ this.unsubscribeInput = tui.addInputListener((data) => {
96
+ if (
97
+ matchesKey(data, Key.ctrl("o")) &&
98
+ (this.context.reading || this.overlayHandle?.isFocused() === true)
99
+ ) {
100
+ this.markInteraction();
101
+ this.toggleContext();
102
+ return { consume: true };
103
+ }
104
+ return;
105
+ });
106
+ this.channel = new PromptChannel(
107
+ {
108
+ pi: deps.pi,
109
+ observer: deps.observer,
110
+ timeout: deps.timeout,
111
+ signal: deps.signal,
112
+ promptId: deps.promptId,
113
+ },
114
+ this,
115
+ );
116
+ this.unsubscribeShutdown = deps.pi.on("session_shutdown", this.abort);
117
+
118
+ deps.signal?.addEventListener("abort", this.abort, { once: true });
119
+ if (deps.signal?.aborted === true) {
120
+ this.finish(true);
121
+ }
122
+ }
123
+
124
+ get focused(): boolean {
125
+ return this.editor.focused;
126
+ }
127
+
128
+ set focused(value: boolean) {
129
+ if (this.editor.focused === value) {
130
+ return;
131
+ }
132
+ this.editor.focused = value;
133
+ this.cachedLines = undefined;
134
+ this.tui.requestRender();
135
+ }
136
+
137
+ get questions(): NormalizedQuestion[] {
138
+ return this.deps.questions;
139
+ }
140
+
141
+ get answers(): Map<string, Answer> {
142
+ return this.sheet.answers;
143
+ }
144
+
145
+ get customAnswers(): Map<string, string> {
146
+ return this.sheet.custom;
147
+ }
148
+
149
+ get timeoutActive(): boolean {
150
+ return this.deps.timeout.active;
151
+ }
152
+
153
+ invalidate(): void {
154
+ this.cachedLines = undefined;
155
+ }
156
+
157
+ dispose(): void {
158
+ this.finish(true);
159
+ }
160
+
161
+ /** Release subscriptions and widget state without completing the prompt. */
162
+ release(): void {
163
+ if (this.finished) {
164
+ return;
165
+ }
166
+ this.finished = true;
167
+ this.deps.signal?.removeEventListener("abort", this.abort);
168
+ this.channel.release();
169
+ this.unsubscribeShutdown();
170
+ this.unsubscribeInput();
171
+ this.deps.ui.setWidget(this.widgetKey, undefined);
172
+ }
173
+
174
+ attachOverlay(handle: OverlayHandle): void {
175
+ this.overlayHandle = handle;
176
+ this.channel.announce();
177
+ }
178
+
179
+ finish(cancelled: boolean): void {
180
+ if (this.finished) {
181
+ return;
182
+ }
183
+ if (this.context.reading) {
184
+ this.overlayHandle?.focus();
185
+ }
186
+ this.release();
187
+ this.deps.onDone({ answers: this.sheet.ordered(this.deps.questions), cancelled });
188
+ }
189
+
190
+ current(): NormalizedQuestion | undefined {
191
+ return this.deps.questions[this.tab];
192
+ }
193
+
194
+ customOption(): string {
195
+ return uniqueOptionLabel(CUSTOM_OPTION, this.current()?.options ?? []);
196
+ }
197
+
198
+ choices(): string[] {
199
+ return [...(this.current()?.options ?? []), this.customOption()];
200
+ }
201
+
202
+ allAnswered(): boolean {
203
+ return this.deps.questions.every((question) => this.sheet.has(question.id));
204
+ }
205
+
206
+ selectedSet(question: NormalizedQuestion): Set<string> {
207
+ return this.sheet.selectedSet(question);
208
+ }
209
+
210
+ keyText(binding: Keybinding): string {
211
+ return this.keys.getKeys(binding).join("/");
212
+ }
213
+
214
+ renderEditor(width: number): string[] {
215
+ return this.editor.render(width);
216
+ }
217
+
218
+ private refresh(): void {
219
+ this.cachedLines = undefined;
220
+ this.tui.requestRender();
221
+ }
222
+
223
+ private markInteraction(): void {
224
+ if (!this.deps.timeout.stop()) {
225
+ return;
226
+ }
227
+ this.refresh();
228
+ this.channel.publishState();
229
+ }
230
+
231
+ private toggleContext(): void {
232
+ if (this.overlayHandle === undefined) {
233
+ return;
234
+ }
235
+ this.context.toggle(this.overlayHandle);
236
+ if (this.context.reading) {
237
+ this.reservedRows = 0;
238
+ }
239
+ this.refresh();
240
+ }
241
+
242
+ private moveTab(next: number): void {
243
+ const count = this.deps.questions.length + 1;
244
+ this.tab = (next + count) % count;
245
+ this.option = 0;
246
+ this.scrolls.resetForWidth();
247
+ this.scrolls.reviewScroll = undefined;
248
+ this.editing = false;
249
+ this.editor.setText("");
250
+ this.channel.stateChanged();
251
+ this.refresh();
252
+ }
253
+
254
+ private toggleMultiChoice(question: NormalizedQuestion, choice: string): void {
255
+ this.sheet.toggleMultiChoice(question, choice);
256
+ this.sheet.syncMultiAnswer(question);
257
+ this.channel.stateChanged();
258
+ this.scrolls.labelScroll = 0;
259
+ this.refresh();
260
+ }
261
+
262
+ private startCustomEdit(question: NormalizedQuestion): void {
263
+ const existing = this.sheet.get(question.id);
264
+ let currentCustom: string | undefined;
265
+ if (question.multiSelect) {
266
+ currentCustom = this.sheet.custom.get(question.id);
267
+ } else if (existing !== undefined && existing.wasCustom) {
268
+ currentCustom = existing.answer;
269
+ }
270
+ this.editor.setText(currentCustom ?? "");
271
+ this.editing = true;
272
+ this.scrolls.resetForWidth();
273
+ this.channel.stateChanged();
274
+ this.refresh();
275
+ }
276
+
277
+ private saveMultiCustomAnswer(question: NormalizedQuestion, answer: string): void {
278
+ this.sheet.setMultiCustom(question, answer);
279
+ this.sheet.syncMultiAnswer(question);
280
+ this.channel.stateChanged();
281
+ this.refresh();
282
+ }
283
+
284
+ private moveForwardIfAnswered(question: NormalizedQuestion): void {
285
+ if (this.sheet.has(question.id)) {
286
+ this.moveTab(Math.min(this.tab + 1, this.submitTab));
287
+ } else {
288
+ this.refresh();
289
+ }
290
+ }
291
+
292
+ private saveSingleAnswer(answer: string, wasCustom: boolean): void {
293
+ const question = this.current();
294
+ if (question === undefined) {
295
+ return;
296
+ }
297
+ this.sheet.setSingle(question, answer, wasCustom);
298
+ this.channel.stateChanged();
299
+ if (this.deps.questions.length === 1) {
300
+ this.finish(false);
301
+ } else {
302
+ this.moveTab(Math.min(this.tab + 1, this.submitTab));
303
+ }
304
+ }
305
+
306
+ private submitCustomAnswer(value: string): void {
307
+ const answer = clean(value);
308
+ const question = this.current();
309
+ this.editing = false;
310
+ this.editor.setText("");
311
+ if (answer === undefined || question === undefined) {
312
+ this.channel.stateChanged();
313
+ this.refresh();
314
+ return;
315
+ }
316
+ if (question.multiSelect) {
317
+ this.saveMultiCustomAnswer(question, answer);
318
+ } else {
319
+ this.saveSingleAnswer(answer, true);
320
+ }
321
+ }
322
+
323
+ applyRemoteAnswer(question: NormalizedQuestion, validated: ValidatedRemoteAnswer): void {
324
+ if (validated.kind === "text") {
325
+ if (question.multiSelect) {
326
+ this.saveMultiCustomAnswer(question, validated.text.trim());
327
+ this.moveForwardIfAnswered(question);
328
+ } else {
329
+ this.saveSingleAnswer(validated.text.trim(), true);
330
+ }
331
+ return;
332
+ }
333
+ this.sheet.applyOptions(question, validated.options);
334
+ this.sheet.syncMultiAnswer(question);
335
+ this.channel.stateChanged();
336
+ this.moveForwardIfAnswered(question);
337
+ }
338
+
339
+ handleInput(data: string): void {
340
+ if (this.finished) {
341
+ return;
342
+ }
343
+ this.markInteraction();
344
+ if (this.editing) {
345
+ this.handleEditingInput(data);
346
+ return;
347
+ }
348
+ if (this.navigateTabs(data)) {
349
+ return;
350
+ }
351
+ if (this.keys.matches(data, "tui.select.cancel")) {
352
+ this.finish(true);
353
+ return;
354
+ }
355
+ if (this.tab === this.submitTab) {
356
+ this.handleSubmitTabInput(data);
357
+ return;
358
+ }
359
+ this.handleOptionInput(data);
360
+ }
361
+
362
+ private handleEditingInput(data: string): void {
363
+ if (this.scrolls.maxQuestionScroll > 0 && matchesKey(data, Key.shift("pageUp"))) {
364
+ this.scrolls.pageQuestionUp();
365
+ this.refresh();
366
+ return;
367
+ }
368
+ if (this.scrolls.maxQuestionScroll > 0 && matchesKey(data, Key.shift("pageDown"))) {
369
+ this.scrolls.pageQuestionDown();
370
+ this.refresh();
371
+ return;
372
+ }
373
+ if (this.keys.matches(data, "tui.select.cancel")) {
374
+ this.editing = false;
375
+ this.editor.setText("");
376
+ this.channel.stateChanged();
377
+ this.refresh();
378
+ return;
379
+ }
380
+ this.editor.handleInput(data);
381
+ this.refresh();
382
+ }
383
+
384
+ private handleSubmitTabInput(data: string): void {
385
+ // Fullscreen reserves plain Page keys for transcript scrolling.
386
+ if (this.scrolls.maxReviewScroll > 0 && this.isPageUp(data)) {
387
+ this.scrolls.pageReviewUp();
388
+ this.refresh();
389
+ return;
390
+ }
391
+ if (this.scrolls.maxReviewScroll > 0 && this.isPageDown(data)) {
392
+ this.scrolls.pageReviewDown();
393
+ this.refresh();
394
+ return;
395
+ }
396
+ if (this.keys.matches(data, "tui.select.confirm") && this.allAnswered()) {
397
+ this.finish(false);
398
+ }
399
+ }
400
+
401
+ private handleOptionInput(data: string): void {
402
+ if (this.scrollQuestionOrChoices(data)) {
403
+ return;
404
+ }
405
+ if (this.moveOptionCursor(data)) {
406
+ return;
407
+ }
408
+ const question = this.current();
409
+ if (question === undefined) {
410
+ return;
411
+ }
412
+ this.selectOption(data, question);
413
+ }
414
+
415
+ private isPageUp(data: string): boolean {
416
+ return this.keys.matches(data, "tui.select.pageUp") || matchesKey(data, Key.shift("pageUp"));
417
+ }
418
+
419
+ private isPageDown(data: string): boolean {
420
+ return (
421
+ this.keys.matches(data, "tui.select.pageDown") || matchesKey(data, Key.shift("pageDown"))
422
+ );
423
+ }
424
+
425
+ private navigateTabs(data: string): boolean {
426
+ if (
427
+ this.showTabs &&
428
+ (matchesKey(data, Key.right) || this.keys.matches(data, "tui.input.tab"))
429
+ ) {
430
+ this.moveTab(this.tab + 1);
431
+ return true;
432
+ }
433
+ if (this.showTabs && (matchesKey(data, Key.left) || matchesKey(data, Key.shift("tab")))) {
434
+ this.moveTab(this.tab - 1);
435
+ return true;
436
+ }
437
+ return false;
438
+ }
439
+
440
+ private scrollQuestionOrChoices(data: string): boolean {
441
+ const canScroll = this.scrolls.maxQuestionScroll > 0 || this.scrolls.maxLabelScroll > 0;
442
+ if (!canScroll) {
443
+ return false;
444
+ }
445
+ if (this.isPageUp(data)) {
446
+ this.scrolls.pageChoiceUp();
447
+ this.refresh();
448
+ return true;
449
+ }
450
+ if (this.isPageDown(data)) {
451
+ this.scrolls.pageChoiceDown();
452
+ this.refresh();
453
+ return true;
454
+ }
455
+ return false;
456
+ }
457
+
458
+ private moveOptionCursor(data: string): boolean {
459
+ if (this.keys.matches(data, "tui.select.up")) {
460
+ this.option = Math.max(0, this.option - 1);
461
+ this.scrolls.labelScroll = 0;
462
+ this.refresh();
463
+ return true;
464
+ }
465
+ if (this.keys.matches(data, "tui.select.down")) {
466
+ this.option = Math.min(this.choices().length - 1, this.option + 1);
467
+ this.scrolls.labelScroll = 0;
468
+ this.refresh();
469
+ return true;
470
+ }
471
+ return false;
472
+ }
473
+
474
+ private selectOption(data: string, question: NormalizedQuestion): void {
475
+ const options = this.choices();
476
+ if (question.multiSelect && matchesKey(data, Key.space)) {
477
+ this.toggleSpaceSelection(question, options);
478
+ return;
479
+ }
480
+ if (this.keys.matches(data, "tui.select.confirm")) {
481
+ this.confirmOption(question, options);
482
+ }
483
+ }
484
+
485
+ private toggleSpaceSelection(question: NormalizedQuestion, options: string[]): void {
486
+ const picked = options[this.option];
487
+ if (picked === this.customOption()) {
488
+ this.startCustomEdit(question);
489
+ } else if (picked !== undefined) {
490
+ this.toggleMultiChoice(question, picked);
491
+ }
492
+ }
493
+
494
+ private confirmOption(question: NormalizedQuestion, options: string[]): void {
495
+ const picked = options[this.option];
496
+ if (picked === this.customOption()) {
497
+ this.startCustomEdit(question);
498
+ } else if (picked !== undefined && question.multiSelect) {
499
+ this.moveForwardIfAnswered(question);
500
+ } else if (picked !== undefined) {
501
+ this.saveSingleAnswer(picked, false);
502
+ }
503
+ }
504
+
505
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
506
+ this.markInteraction();
507
+ if (event.type === "click" && event.button === "left" && this.context.contains(event)) {
508
+ this.toggleContext();
509
+ return { handled: true };
510
+ }
511
+ return;
512
+ }
513
+
514
+ render(width: number): string[] {
515
+ if (this.context.reading) {
516
+ return this.context.renderHint(this.theme, width);
517
+ }
518
+ const height = this.tui.terminal.rows;
519
+ if (
520
+ this.cachedLines !== undefined &&
521
+ this.cachedWidth === width &&
522
+ this.cachedHeight === height
523
+ ) {
524
+ return this.cachedLines;
525
+ }
526
+ if (this.cachedWidth !== width) {
527
+ this.scrolls.resetForWidth();
528
+ }
529
+ const page = buildFormPage(this, width);
530
+ if (this.tab === this.submitTab) {
531
+ this.scrolls.applyReviewScrolling(page, this, height);
532
+ } else {
533
+ this.scrolls.applyQuestionScrolling(page, this, height);
534
+ }
535
+ this.cachedWidth = width;
536
+ this.cachedHeight = height;
537
+ this.cachedLines = page.lines;
538
+ this.context.trackHint(page);
539
+ if (this.reservedRows !== page.lines.length) {
540
+ this.reservedRows = page.lines.length;
541
+ this.tui.requestRender();
542
+ }
543
+ return page.lines;
544
+ }
545
+ }