@geoqiao/pi-ask 1.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/LICENSE +22 -0
  3. package/README.md +282 -0
  4. package/docs/README.md +33 -0
  5. package/docs/configuration.md +406 -0
  6. package/docs/contract.md +309 -0
  7. package/docs/remote-events.md +187 -0
  8. package/package.json +130 -0
  9. package/skills/ask-user/SKILL.md +110 -0
  10. package/src/answer-commands.ts +361 -0
  11. package/src/answer-extraction.ts +354 -0
  12. package/src/ask-payload-store.ts +86 -0
  13. package/src/ask-settings-command.ts +14 -0
  14. package/src/ask-tool-helpers.ts +172 -0
  15. package/src/ask-tool.ts +84 -0
  16. package/src/config/defaults.ts +216 -0
  17. package/src/config/migrate.ts +70 -0
  18. package/src/config/migrations/index.ts +139 -0
  19. package/src/config/migrations/types.ts +10 -0
  20. package/src/config/schema.ts +287 -0
  21. package/src/config/store.ts +227 -0
  22. package/src/constants/keymaps.ts +721 -0
  23. package/src/constants/text.ts +12 -0
  24. package/src/constants/ui.ts +22 -0
  25. package/src/index.ts +30 -0
  26. package/src/math.ts +3 -0
  27. package/src/notifications.ts +119 -0
  28. package/src/remote-ask.ts +563 -0
  29. package/src/result-format.ts +157 -0
  30. package/src/result.ts +23 -0
  31. package/src/schema.ts +74 -0
  32. package/src/state/answers.ts +251 -0
  33. package/src/state/create.ts +18 -0
  34. package/src/state/editor.ts +70 -0
  35. package/src/state/navigation.ts +86 -0
  36. package/src/state/normalize.ts +326 -0
  37. package/src/state/question-type.ts +128 -0
  38. package/src/state/result.ts +263 -0
  39. package/src/state/selectors.ts +135 -0
  40. package/src/state/transitions.ts +330 -0
  41. package/src/state/view.ts +28 -0
  42. package/src/text.ts +98 -0
  43. package/src/types.ts +169 -0
  44. package/src/ui/auto-submit.ts +36 -0
  45. package/src/ui/autocomplete.ts +52 -0
  46. package/src/ui/controller.ts +645 -0
  47. package/src/ui/dismiss-guard.ts +26 -0
  48. package/src/ui/input.ts +160 -0
  49. package/src/ui/render-frame.ts +235 -0
  50. package/src/ui/render-helpers.ts +385 -0
  51. package/src/ui/render-question.ts +288 -0
  52. package/src/ui/render-submit.ts +168 -0
  53. package/src/ui/render-types.ts +33 -0
  54. package/src/ui/render.ts +53 -0
  55. package/src/ui/review-shortcuts.ts +43 -0
  56. package/src/ui/settings-list.ts +461 -0
  57. package/src/ui/show-settings.ts +37 -0
  58. package/src/ui/view-models/question.ts +203 -0
  59. package/src/ui/view-models/review.ts +100 -0
@@ -0,0 +1,645 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Editor, type EditorTheme } from "@earendil-works/pi-tui";
3
+ import type { AskConfig } from "../config/schema.ts";
4
+ import { getAskConfigStore } from "../config/store.ts";
5
+ import {
6
+ createQuestionWaitingNotification,
7
+ notifyQuestionWaiting,
8
+ } from "../notifications.ts";
9
+ import {
10
+ applyRemoteAskResponse,
11
+ type RemoteAskFlowHandle,
12
+ type RemoteAskResponse,
13
+ type RemoteAskRuntime,
14
+ type RemoteAskSource,
15
+ type RemoteAskSubmitResolution,
16
+ } from "../remote-ask.ts";
17
+ import { createInitialState } from "../state/create.ts";
18
+ import {
19
+ getEditorDraft,
20
+ saveEditorDraft,
21
+ submitEditorDraft,
22
+ syncStateToSelection,
23
+ } from "../state/editor.ts";
24
+ import { cycleCurrentQuestionType } from "../state/question-type.ts";
25
+ import { toAskResult } from "../state/result.ts";
26
+ import {
27
+ getCurrentOption,
28
+ getCurrentQuestion,
29
+ isSubmitTab,
30
+ } from "../state/selectors.ts";
31
+ import {
32
+ applyNumberShortcut,
33
+ cancelFlow,
34
+ confirmCurrentSelection,
35
+ dismissFlow,
36
+ enterOptionNoteMode,
37
+ enterQuestionNoteMode,
38
+ moveOption,
39
+ moveTab,
40
+ toggleCurrentMultiOption,
41
+ } from "../state/transitions.ts";
42
+ import { isEditingView } from "../state/view.ts";
43
+ import type { AskParams, AskResult, AskState } from "../types.ts";
44
+ import { maybeAutoSubmitState } from "./auto-submit.ts";
45
+ import { createAskAutocompleteProvider } from "./autocomplete.ts";
46
+ import {
47
+ DIRTY_DISMISS_NOTICE,
48
+ shouldConfirmDirtyDismiss,
49
+ shouldDiscardAfterConfirmation,
50
+ } from "./dismiss-guard.ts";
51
+ import type { AskInputCommand } from "./input.ts";
52
+ import { getInputCommand } from "./input.ts";
53
+ import { renderAskScreen } from "./render.ts";
54
+ import {
55
+ getReviewShortcutHint,
56
+ resolveReviewShortcutDoublePress,
57
+ } from "./review-shortcuts.ts";
58
+ import { showAskSettings } from "./show-settings.ts";
59
+
60
+ type CustomCallback = Parameters<ExtensionContext["ui"]["custom"]>[0];
61
+ type CustomCallbackArgs = CustomCallback extends (...args: infer T) => unknown
62
+ ? T
63
+ : never;
64
+ type Tui = CustomCallbackArgs[0];
65
+ type Theme = CustomCallbackArgs[1];
66
+ type Keybindings = CustomCallbackArgs[2];
67
+ type Done = (result: AskResult) => void;
68
+ interface AskFlowOptions {
69
+ allowFreeform?: boolean;
70
+ presentSingleAsMulti?: boolean;
71
+ remote?: {
72
+ runtime: RemoteAskRuntime;
73
+ source: RemoteAskSource;
74
+ toolCallId?: string;
75
+ };
76
+ }
77
+
78
+ type AskFlowParams = AskParams &
79
+ Pick<ExtensionContext, "cwd"> & {
80
+ config: AskConfig;
81
+ configNotice?: string;
82
+ ctx: ExtensionContext;
83
+ flowOptions: AskFlowOptions;
84
+ };
85
+
86
+ interface AskFlowController {
87
+ config: AskConfig;
88
+ configNotice?: string;
89
+ ctx: ExtensionContext;
90
+ dismissNotice?: string;
91
+ done: Done;
92
+ editor: Editor;
93
+ pendingQuestionTypeChangeQuestionId?: string;
94
+ pendingReviewShortcutActionIndex?: number;
95
+ remoteFlow?: RemoteAskFlowHandle;
96
+ settingsOpen: boolean;
97
+ state: AskState;
98
+ suppressAutoInputForSelection: boolean;
99
+ theme: Theme;
100
+ tui: Tui;
101
+ unsubscribeConfig: () => void;
102
+ }
103
+
104
+ export async function runAskFlow(
105
+ ctx: ExtensionContext,
106
+ params: AskParams,
107
+ options: AskFlowOptions = {}
108
+ ): Promise<AskResult> {
109
+ const store = getAskConfigStore();
110
+ const { config, notice } = await store.ensureLoaded();
111
+ const flowOptions = {
112
+ ...options,
113
+ presentSingleAsMulti:
114
+ options.presentSingleAsMulti ?? config.behaviour.presentSingleAsMulti,
115
+ };
116
+ if (ctx.mode !== "tui") {
117
+ return {
118
+ ...toAskResult(createInitialState(params, flowOptions)),
119
+ cancelled: true,
120
+ };
121
+ }
122
+ return ctx.ui.custom<AskResult>((...args) =>
123
+ createAskFlowController(args, {
124
+ ...params,
125
+ config,
126
+ configNotice: notice?.text,
127
+ cwd: ctx.cwd,
128
+ ctx,
129
+ flowOptions,
130
+ })
131
+ );
132
+ }
133
+
134
+ function createAskFlowController(
135
+ [tui, theme, _keybindings, done]: [
136
+ Tui,
137
+ Theme,
138
+ Keybindings,
139
+ (result: AskResult) => void,
140
+ ],
141
+ params: AskFlowParams
142
+ ) {
143
+ const controller: AskFlowController = {
144
+ config: params.config,
145
+ configNotice: params.configNotice,
146
+ ctx: params.ctx,
147
+ dismissNotice: undefined,
148
+ done,
149
+ editor: createEditor(tui, theme, params.cwd),
150
+ settingsOpen: false,
151
+ state: createInitialState(params, params.flowOptions),
152
+ suppressAutoInputForSelection: false,
153
+ pendingQuestionTypeChangeQuestionId: undefined,
154
+ pendingReviewShortcutActionIndex: undefined,
155
+ theme,
156
+ tui,
157
+ unsubscribeConfig: () => {
158
+ // replaced immediately after controller creation
159
+ },
160
+ };
161
+
162
+ controller.unsubscribeConfig = getAskConfigStore().subscribe((config) => {
163
+ controller.config = config;
164
+ controller.configNotice = undefined;
165
+ controller.state = maybeAutoSubmitState(
166
+ controller.state,
167
+ controller.config
168
+ );
169
+ refresh(controller);
170
+ maybeFinish(controller);
171
+ });
172
+
173
+ controller.editor.onSubmit = (value) => submitEditor(controller, value);
174
+ controller.remoteFlow = startRemoteFlow(controller, params);
175
+ syncSelection(controller);
176
+ notifyCurrentQuestion(controller).catch(() => {
177
+ // Notification failures are best-effort and must not affect the ask flow.
178
+ });
179
+
180
+ return {
181
+ get focused() {
182
+ return controller.editor.focused;
183
+ },
184
+ set focused(value: boolean) {
185
+ controller.editor.focused = value;
186
+ },
187
+ render: (width: number) => renderController(controller, width),
188
+ invalidate() {
189
+ controller.editor.invalidate();
190
+ },
191
+ handleInput(data: string) {
192
+ handleControllerInput(controller, data);
193
+ },
194
+ dispose() {
195
+ controller.remoteFlow?.dispose();
196
+ controller.unsubscribeConfig();
197
+ },
198
+ };
199
+ }
200
+
201
+ function renderController(
202
+ controller: AskFlowController,
203
+ width: number
204
+ ): string[] {
205
+ return renderAskScreen({
206
+ config: controller.config,
207
+ editor: controller.editor,
208
+ footerNotice: getFooterNotice(controller),
209
+ reviewShortcutHint: getActiveReviewShortcutHint(controller),
210
+ state: controller.state,
211
+ theme: controller.theme,
212
+ width,
213
+ });
214
+ }
215
+
216
+ function handleControllerInput(controller: AskFlowController, data: string) {
217
+ controller.editor.disableSubmit = !isNativeEditorSubmitEnabled(controller);
218
+ const command = getInputCommand(
219
+ controller.state,
220
+ controller.config,
221
+ data,
222
+ isEditingView(controller.state) ? controller.editor.getText() : ""
223
+ );
224
+ if (isEditingView(controller.state)) {
225
+ handleEditingCommand(controller, command, data);
226
+ return;
227
+ }
228
+ handleNavigationCommand(controller, command);
229
+ }
230
+
231
+ function isNativeEditorSubmitEnabled(controller: AskFlowController): boolean {
232
+ if (controller.state.view.kind === "input") {
233
+ return controller.config.keymaps.editor.submit.includes("enter");
234
+ }
235
+ if (controller.state.view.kind === "note") {
236
+ return controller.config.keymaps.noteEditor.save.includes("enter");
237
+ }
238
+ return true;
239
+ }
240
+
241
+ function handleEditingCommand(
242
+ controller: AskFlowController,
243
+ command: AskInputCommand,
244
+ data: string
245
+ ) {
246
+ if (command.kind === "dismiss") {
247
+ handleExitFlow(controller, dismissFlow(controller.state));
248
+ return;
249
+ }
250
+ if (command.kind === "showSettings") {
251
+ showSettingsModal(controller);
252
+ return;
253
+ }
254
+ if (command.kind === "editMoveTab") {
255
+ commitSavedEditorNavigation(controller, moveTab, command.delta);
256
+ return;
257
+ }
258
+ if (command.kind === "editMoveOption") {
259
+ commitSavedEditorNavigation(controller, moveOption, command.delta);
260
+ return;
261
+ }
262
+ if (command.kind === "editClose") {
263
+ closeEditor(controller);
264
+ return;
265
+ }
266
+ if (command.kind === "editSubmit") {
267
+ submitEditor(controller, controller.editor.getText());
268
+ return;
269
+ }
270
+ if (command.kind === "delegateToEditor") {
271
+ controller.editor.handleInput(data);
272
+ refresh(controller);
273
+ }
274
+ }
275
+
276
+ function handleNavigationCommand(
277
+ controller: AskFlowController,
278
+ command: AskInputCommand
279
+ ) {
280
+ switch (command.kind) {
281
+ case "moveTab":
282
+ clearReviewShortcutPending(controller);
283
+ clearQuestionTypeChangePending(controller);
284
+ commitState(controller, moveTab(controller.state, command.delta));
285
+ return;
286
+ case "moveOption":
287
+ clearReviewShortcutPending(controller);
288
+ clearQuestionTypeChangePending(controller);
289
+ commitState(controller, moveOption(controller.state, command.delta));
290
+ return;
291
+ case "toggleMulti":
292
+ clearReviewShortcutPending(controller);
293
+ clearQuestionTypeChangePending(controller);
294
+ handleToggleCurrentOption(controller);
295
+ return;
296
+ case "changeQuestionType":
297
+ clearReviewShortcutPending(controller);
298
+ handleChangeQuestionType(controller);
299
+ return;
300
+ case "openQuestionNote":
301
+ clearQuestionTypeChangePending(controller);
302
+ openQuestionNote(controller);
303
+ return;
304
+ case "openOptionNote":
305
+ clearQuestionTypeChangePending(controller);
306
+ openOptionNote(controller);
307
+ return;
308
+ case "confirm":
309
+ clearReviewShortcutPending(controller);
310
+ clearQuestionTypeChangePending(controller);
311
+ commitState(controller, confirmCurrentSelection(controller.state), {
312
+ finish: true,
313
+ });
314
+ return;
315
+ case "cancel":
316
+ clearReviewShortcutPending(controller);
317
+ clearQuestionTypeChangePending(controller);
318
+ handleExitFlow(controller, cancelFlow(controller.state));
319
+ return;
320
+ case "numberShortcut":
321
+ if (handleReviewShortcutNumber(controller, command.digit)) {
322
+ return;
323
+ }
324
+ clearReviewShortcutPending(controller);
325
+ clearQuestionTypeChangePending(controller);
326
+ commitState(
327
+ controller,
328
+ applyNumberShortcut(controller.state, command.digit)
329
+ );
330
+ return;
331
+ case "dismiss":
332
+ clearReviewShortcutPending(controller);
333
+ clearQuestionTypeChangePending(controller);
334
+ handleExitFlow(controller, dismissFlow(controller.state));
335
+ return;
336
+ case "showSettings":
337
+ showSettingsModal(controller);
338
+ return;
339
+ case "ignore":
340
+ case "editMoveTab":
341
+ case "editMoveOption":
342
+ case "editClose":
343
+ case "editSubmit":
344
+ case "delegateToEditor":
345
+ return;
346
+ default:
347
+ return;
348
+ }
349
+ }
350
+
351
+ function handleToggleCurrentOption(controller: AskFlowController) {
352
+ const question = getCurrentQuestion(controller.state);
353
+ if (!question) {
354
+ return;
355
+ }
356
+ commitState(controller, toggleCurrentMultiOption(controller.state));
357
+ }
358
+
359
+ function handleChangeQuestionType(controller: AskFlowController) {
360
+ const question = getCurrentQuestion(controller.state);
361
+ if (!question || isSubmitTab(controller.state)) {
362
+ return;
363
+ }
364
+ const confirmed =
365
+ controller.pendingQuestionTypeChangeQuestionId === question.id;
366
+ const result = cycleCurrentQuestionType(controller.state, { confirmed });
367
+ controller.dismissNotice = result.notice;
368
+ if (result.needsConfirmation) {
369
+ controller.pendingQuestionTypeChangeQuestionId = question.id;
370
+ refresh(controller);
371
+ return;
372
+ }
373
+ clearQuestionTypeChangePending(controller);
374
+ commitState(controller, result.state, { finish: true });
375
+ }
376
+
377
+ function openQuestionNote(controller: AskFlowController) {
378
+ const question = getCurrentQuestion(controller.state);
379
+ if (!question || isSubmitTab(controller.state)) {
380
+ return;
381
+ }
382
+ commitState(
383
+ controller,
384
+ enterQuestionNoteMode(controller.state, question.id),
385
+ {
386
+ syncSelection: false,
387
+ }
388
+ );
389
+ }
390
+
391
+ function openOptionNote(controller: AskFlowController) {
392
+ const question = getCurrentQuestion(controller.state);
393
+ const option = getCurrentOption(controller.state);
394
+ if (
395
+ !(question && option) ||
396
+ option.isCustomOption ||
397
+ isSubmitTab(controller.state)
398
+ ) {
399
+ return;
400
+ }
401
+ commitState(
402
+ controller,
403
+ enterOptionNoteMode(controller.state, question.id, option.value),
404
+ { syncSelection: false }
405
+ );
406
+ }
407
+
408
+ function commitState(
409
+ controller: AskFlowController,
410
+ nextState: AskState,
411
+ options: { finish?: boolean; syncSelection?: boolean } = {}
412
+ ) {
413
+ if (nextState.activeTabIndex !== controller.state.activeTabIndex) {
414
+ clearFooterNotices(controller);
415
+ }
416
+ controller.suppressAutoInputForSelection = false;
417
+ controller.state = nextState;
418
+ if (options.syncSelection !== false) {
419
+ syncSelection(controller);
420
+ }
421
+ controller.state = maybeAutoSubmitState(controller.state, controller.config);
422
+ hydrateEditor(controller);
423
+ refresh(controller);
424
+ if (options.finish) {
425
+ maybeFinish(controller);
426
+ }
427
+ }
428
+
429
+ function submitEditor(controller: AskFlowController, value: string) {
430
+ controller.suppressAutoInputForSelection = false;
431
+ const nextState = submitEditorDraft(controller.state, value);
432
+ if (nextState.activeTabIndex !== controller.state.activeTabIndex) {
433
+ clearFooterNotices(controller);
434
+ }
435
+ controller.state = nextState;
436
+ syncSelection(controller);
437
+ controller.state = maybeAutoSubmitState(controller.state, controller.config);
438
+ hydrateEditor(controller);
439
+ refresh(controller);
440
+ maybeFinish(controller);
441
+ }
442
+
443
+ function commitSavedEditorNavigation(
444
+ controller: AskFlowController,
445
+ navigate: (state: AskState, delta: 1 | -1) => AskState,
446
+ delta: 1 | -1
447
+ ) {
448
+ commitState(controller, navigate(saveEditorState(controller), delta));
449
+ }
450
+
451
+ function closeEditor(controller: AskFlowController) {
452
+ const nextState = saveEditorState(controller);
453
+ controller.suppressAutoInputForSelection = nextState.view.kind !== "input";
454
+ controller.state = nextState;
455
+ refresh(controller);
456
+ }
457
+
458
+ function handleExitFlow(controller: AskFlowController, nextState: AskState) {
459
+ if (!shouldRequestDismissConfirmation(controller)) {
460
+ commitState(controller, nextState, { finish: true });
461
+ return;
462
+ }
463
+ if (shouldDiscardAfterConfirmation(!!controller.dismissNotice)) {
464
+ commitState(controller, nextState, { finish: true });
465
+ return;
466
+ }
467
+ controller.dismissNotice = DIRTY_DISMISS_NOTICE;
468
+ refresh(controller);
469
+ }
470
+
471
+ function shouldRequestDismissConfirmation(
472
+ controller: AskFlowController
473
+ ): boolean {
474
+ return shouldConfirmDirtyDismiss({
475
+ config: controller.config,
476
+ state: controller.state,
477
+ editingText: isEditingView(controller.state)
478
+ ? controller.editor.getText()
479
+ : "",
480
+ });
481
+ }
482
+
483
+ function clearFooterNotices(controller: AskFlowController) {
484
+ controller.configNotice = undefined;
485
+ controller.dismissNotice = undefined;
486
+ }
487
+
488
+ function clearReviewShortcutPending(controller: AskFlowController) {
489
+ controller.pendingReviewShortcutActionIndex = undefined;
490
+ }
491
+
492
+ function clearQuestionTypeChangePending(controller: AskFlowController) {
493
+ controller.pendingQuestionTypeChangeQuestionId = undefined;
494
+ }
495
+
496
+ function getActiveReviewShortcutHint(
497
+ controller: AskFlowController
498
+ ): string | undefined {
499
+ if (
500
+ !(
501
+ isSubmitTab(controller.state) &&
502
+ controller.config.behaviour.doublePressReviewShortcuts
503
+ )
504
+ ) {
505
+ return;
506
+ }
507
+ return getReviewShortcutHint(controller.pendingReviewShortcutActionIndex);
508
+ }
509
+
510
+ function handleReviewShortcutNumber(
511
+ controller: AskFlowController,
512
+ digit: number
513
+ ): boolean {
514
+ if (
515
+ !(
516
+ isSubmitTab(controller.state) &&
517
+ controller.config.behaviour.doublePressReviewShortcuts
518
+ )
519
+ ) {
520
+ return false;
521
+ }
522
+
523
+ const resolution = resolveReviewShortcutDoublePress(
524
+ digit,
525
+ controller.pendingReviewShortcutActionIndex
526
+ );
527
+ if (resolution.actionIndex === undefined) {
528
+ return false;
529
+ }
530
+
531
+ controller.pendingReviewShortcutActionIndex = resolution.pendingActionIndex;
532
+ const nextState = resolution.confirmed
533
+ ? applyNumberShortcut(controller.state, digit)
534
+ : {
535
+ ...controller.state,
536
+ activeSubmitActionIndex: resolution.actionIndex,
537
+ };
538
+ commitState(controller, nextState, { finish: resolution.confirmed });
539
+ return true;
540
+ }
541
+
542
+ function getFooterNotice(controller: AskFlowController): string | undefined {
543
+ return controller.dismissNotice ?? controller.configNotice;
544
+ }
545
+
546
+ function showSettingsModal(controller: AskFlowController) {
547
+ if (controller.settingsOpen) {
548
+ return;
549
+ }
550
+ controller.settingsOpen = true;
551
+ showAskSettings(controller.ctx).finally(() => {
552
+ controller.settingsOpen = false;
553
+ refresh(controller);
554
+ });
555
+ }
556
+
557
+ function refresh(controller: AskFlowController) {
558
+ controller.tui.requestRender();
559
+ }
560
+
561
+ async function notifyCurrentQuestion(
562
+ controller: AskFlowController
563
+ ): Promise<void> {
564
+ const question = getCurrentQuestion(controller.state);
565
+ if (!question) {
566
+ return;
567
+ }
568
+ await notifyQuestionWaiting(
569
+ controller.config,
570
+ createQuestionWaitingNotification(question)
571
+ );
572
+ }
573
+
574
+ function maybeFinish(controller: AskFlowController) {
575
+ if (controller.state.completed) {
576
+ const result = toAskResult(controller.state);
577
+ controller.remoteFlow?.complete(result);
578
+ controller.done(result);
579
+ }
580
+ }
581
+
582
+ function startRemoteFlow(
583
+ controller: AskFlowController,
584
+ params: AskFlowParams
585
+ ): RemoteAskFlowHandle | undefined {
586
+ const remote = params.flowOptions.remote;
587
+ if (!remote) {
588
+ return;
589
+ }
590
+ return remote.runtime.startFlow({
591
+ source: remote.source,
592
+ toolCallId: remote.toolCallId,
593
+ title: controller.state.title,
594
+ questions: controller.state.questions,
595
+ onSubmit: (response) => submitRemoteResponse(controller, response),
596
+ });
597
+ }
598
+
599
+ function submitRemoteResponse(
600
+ controller: AskFlowController,
601
+ response: RemoteAskResponse
602
+ ): RemoteAskSubmitResolution {
603
+ const resolution = applyRemoteAskResponse(controller.state, response);
604
+ if (!resolution.ok) {
605
+ return resolution;
606
+ }
607
+ commitState(controller, resolution.state, { finish: true });
608
+ return { ok: true };
609
+ }
610
+
611
+ function hydrateEditor(controller: AskFlowController) {
612
+ controller.editor.setText(getEditorDraft(controller.state));
613
+ }
614
+
615
+ function syncSelection(controller: AskFlowController) {
616
+ if (controller.suppressAutoInputForSelection) {
617
+ return;
618
+ }
619
+ controller.state = syncStateToSelection(controller.state);
620
+ }
621
+
622
+ function saveEditorState(controller: AskFlowController): AskState {
623
+ const text = controller.editor.getText();
624
+ controller.editor.setText("");
625
+ return saveEditorDraft(controller.state, text);
626
+ }
627
+
628
+ function createEditor(tui: Tui, theme: Theme, cwd: string) {
629
+ const editor = new Editor(tui, createEditorTheme(theme));
630
+ editor.setAutocompleteProvider(createAskAutocompleteProvider(cwd));
631
+ return editor;
632
+ }
633
+
634
+ function createEditorTheme(theme: Theme): EditorTheme {
635
+ return {
636
+ borderColor: (text) => theme.fg("accent", text),
637
+ selectList: {
638
+ description: (text) => theme.fg("muted", text),
639
+ noMatch: (text) => theme.fg("warning", text),
640
+ scrollInfo: (text) => theme.fg("dim", text),
641
+ selectedPrefix: (text) => theme.fg("accent", text),
642
+ selectedText: (text) => theme.fg("accent", text),
643
+ },
644
+ };
645
+ }
@@ -0,0 +1,26 @@
1
+ import type { AskConfig } from "../config/schema.ts";
2
+ import type { AskState } from "../types.ts";
3
+
4
+ export const DIRTY_DISMISS_NOTICE =
5
+ "Unsaved ask answers or drafts. Press cancel/dismiss again to discard.";
6
+
7
+ export function shouldConfirmDirtyDismiss(args: {
8
+ config: AskConfig;
9
+ state: AskState;
10
+ editingText?: string;
11
+ }): boolean {
12
+ if (!args.config.behaviour.confirmDismissWhenDirty) {
13
+ return false;
14
+ }
15
+ return hasDirtyFlowState(args.state, args.editingText ?? "");
16
+ }
17
+
18
+ export function hasDirtyFlowState(state: AskState, editingText = ""): boolean {
19
+ return Object.keys(state.answers).length > 0 || editingText.length > 0;
20
+ }
21
+
22
+ export function shouldDiscardAfterConfirmation(
23
+ confirmationPending: boolean
24
+ ): boolean {
25
+ return confirmationPending;
26
+ }