@mrclrchtr/supi-ask-user 1.3.1 → 1.4.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 (48) hide show
  1. package/README.md +125 -67
  2. package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
  3. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +13 -13
  5. package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
  6. package/node_modules/@mrclrchtr/supi-core/src/{context-provider-registry.ts → context/context-provider-registry.ts} +1 -1
  7. package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/index.ts +13 -13
  9. package/node_modules/@mrclrchtr/supi-core/src/{settings-registry.ts → settings/settings-registry.ts} +1 -1
  10. package/package.json +2 -2
  11. package/src/api.ts +19 -0
  12. package/src/ask-user.ts +65 -131
  13. package/src/index.ts +23 -1
  14. package/src/normalize.ts +153 -142
  15. package/src/render/result.ts +98 -0
  16. package/src/render/transcript.ts +65 -0
  17. package/src/render/tree-summary.ts +10 -0
  18. package/src/schema.ts +41 -38
  19. package/src/session/controller.ts +163 -0
  20. package/src/session/lock.ts +19 -0
  21. package/src/tool/guidance.ts +15 -0
  22. package/src/types.ts +50 -56
  23. package/src/ui/choose-renderer.ts +11 -0
  24. package/src/ui/overlay-actions.ts +42 -0
  25. package/src/ui/overlay-render.ts +196 -0
  26. package/src/ui/overlay-view.ts +216 -0
  27. package/src/ui/overlay.ts +388 -0
  28. package/src/ui/types.ts +35 -0
  29. package/src/flow.ts +0 -224
  30. package/src/format.ts +0 -66
  31. package/src/render/ui-rich-render-editor.ts +0 -51
  32. package/src/render/ui-rich-render-env.ts +0 -15
  33. package/src/render/ui-rich-render-footer.ts +0 -55
  34. package/src/render/ui-rich-render-markdown.ts +0 -33
  35. package/src/render/ui-rich-render-notes.ts +0 -80
  36. package/src/render/ui-rich-render-types.ts +0 -17
  37. package/src/render/ui-rich-render.ts +0 -323
  38. package/src/render.ts +0 -95
  39. package/src/result.ts +0 -90
  40. package/src/ui/ui-rich-handlers.ts +0 -369
  41. package/src/ui/ui-rich-inline.ts +0 -77
  42. package/src/ui/ui-rich-state.ts +0 -179
  43. package/src/ui/ui-rich.ts +0 -144
  44. /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
  45. /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
  46. /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
  47. /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
  48. /package/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
@@ -0,0 +1,388 @@
1
+ import {
2
+ type Component,
3
+ Editor,
4
+ type Focusable,
5
+ Key,
6
+ matchesKey,
7
+ SelectList,
8
+ } from "@earendil-works/pi-tui";
9
+ import { AskUserController } from "../session/controller.ts";
10
+ import type {
11
+ AskUserOutcome,
12
+ NormalizedChoiceQuestion,
13
+ NormalizedQuestionnaire,
14
+ } from "../types.ts";
15
+ import { createActionList } from "./overlay-actions.ts";
16
+ import {
17
+ clampIndex,
18
+ currentCustomValue,
19
+ currentPreviewText,
20
+ currentTextValue,
21
+ makeEditorTheme,
22
+ makeSelectListTheme,
23
+ renderOverlayFrame,
24
+ } from "./overlay-render.ts";
25
+ import {
26
+ buildChoiceItems,
27
+ buildChoiceRows,
28
+ type ChoiceRow,
29
+ choiceRowValue,
30
+ defaultChoiceRowIndex,
31
+ type FocusTarget,
32
+ type OverlayAction,
33
+ type OverlayMode,
34
+ previewOptionIndexForRows,
35
+ } from "./overlay-view.ts";
36
+ import type { OverlayArgs, RunQuestionnaireOptions } from "./types.ts";
37
+
38
+ export async function runOverlayQuestionnaire(
39
+ questionnaire: NormalizedQuestionnaire,
40
+ opts: RunQuestionnaireOptions,
41
+ ): Promise<AskUserOutcome> {
42
+ const controller = new AskUserController(questionnaire);
43
+ if (opts.signal?.aborted) {
44
+ controller.abort();
45
+ return controller.outcome();
46
+ }
47
+
48
+ return opts.ui.custom?.<AskUserOutcome>(
49
+ (tui, theme, _kb, done) =>
50
+ new AskUserOverlay({ tui, theme, controller, done, signal: opts.signal }),
51
+ ) as Promise<AskUserOutcome>;
52
+ }
53
+
54
+ class AskUserOverlay implements Component, Focusable {
55
+ focused = false;
56
+
57
+ private readonly editor: Editor;
58
+ private focus: FocusTarget = "choices";
59
+ private mode: OverlayMode = "choice";
60
+ private closed = false;
61
+ private cachedWidth: number | undefined;
62
+ private cachedLines: string[] | undefined;
63
+ private readonly onAbort: () => void;
64
+
65
+ private choiceRows: ChoiceRow[] = [];
66
+ private choiceRowIndex = 0;
67
+ private previewOptionIndex = 0;
68
+ private choiceList: SelectList | undefined;
69
+
70
+ private textActions: Array<{ action: OverlayAction; label: string }> = [];
71
+ private actionIndex = 0;
72
+ private actionList: SelectList | undefined;
73
+
74
+ constructor(private readonly args: OverlayArgs) {
75
+ this.editor = new Editor(args.tui, makeEditorTheme(args.theme));
76
+ this.editor.onSubmit = (value) => this.handleEditorSubmit(value);
77
+ this.syncCurrentQuestion();
78
+ this.onAbort = () => {
79
+ this.args.controller.abort();
80
+ this.finish();
81
+ };
82
+ args.signal?.addEventListener("abort", this.onAbort);
83
+ }
84
+
85
+ render(width: number): string[] {
86
+ this.editor.focused = this.focus === "editor";
87
+ if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
88
+
89
+ this.cachedWidth = width;
90
+ this.cachedLines = renderOverlayFrame({
91
+ width,
92
+ theme: this.args.theme,
93
+ controller: this.args.controller,
94
+ mode: this.mode,
95
+ focus: this.focus,
96
+ editor: this.editor,
97
+ choiceList: this.choiceList,
98
+ actionList: this.actionList,
99
+ textActionLabels: this.textActions.map(({ label }) => label),
100
+ previewText: currentPreviewText(
101
+ this.args.controller.currentQuestion,
102
+ this.previewOptionIndex,
103
+ ),
104
+ });
105
+ return this.cachedLines;
106
+ }
107
+
108
+ handleInput(data: string): void {
109
+ if (this.closed || this.args.controller.isTerminal) return;
110
+
111
+ if (matchesKey(data, Key.escape)) {
112
+ this.args.controller.cancel();
113
+ this.finish();
114
+ return;
115
+ }
116
+ if (matchesKey(data, Key.left)) {
117
+ if (this.args.controller.goBack()) {
118
+ this.syncCurrentQuestion();
119
+ this.refresh();
120
+ }
121
+ return;
122
+ }
123
+
124
+ switch (this.focus) {
125
+ case "choices":
126
+ this.handleChoiceKey(data);
127
+ return;
128
+ case "actions":
129
+ this.handleActionKey(data);
130
+ return;
131
+ case "editor":
132
+ this.handleEditorKey(data);
133
+ return;
134
+ }
135
+ }
136
+
137
+ invalidate(): void {
138
+ this.cachedLines = undefined;
139
+ this.choiceList?.invalidate();
140
+ this.actionList?.invalidate();
141
+ this.editor.invalidate();
142
+ }
143
+
144
+ dispose(): void {
145
+ this.closed = true;
146
+ this.args.signal?.removeEventListener("abort", this.onAbort);
147
+ }
148
+
149
+ private handleChoiceKey(data: string): void {
150
+ const question = this.args.controller.currentQuestion;
151
+ if (question.type !== "choice" || !this.choiceList) return;
152
+
153
+ if (matchesKey(data, Key.space)) {
154
+ const row = this.choiceRows[this.choiceRowIndex];
155
+ if (row?.kind === "option") this.applyChoiceSelection(question, row.optionIndex, false);
156
+ return;
157
+ }
158
+
159
+ this.choiceList.handleInput(data);
160
+ this.refresh();
161
+ }
162
+
163
+ private handleActionKey(data: string): void {
164
+ const question = this.args.controller.currentQuestion;
165
+ if (!this.actionList) return;
166
+
167
+ if (question.type === "text") {
168
+ if (matchesKey(data, Key.up) && this.actionIndex === 0) {
169
+ this.focus = "editor";
170
+ this.refresh();
171
+ return;
172
+ }
173
+ } else if (matchesKey(data, Key.up) && this.actionIndex === 0) {
174
+ this.focus = "choices";
175
+ this.refresh();
176
+ return;
177
+ }
178
+
179
+ this.actionList.handleInput(data);
180
+ this.refresh();
181
+ }
182
+
183
+ private handleEditorKey(data: string): void {
184
+ if (this.mode === "text" && matchesKey(data, Key.down) && this.textActions.length > 0) {
185
+ this.focus = "actions";
186
+ this.refresh();
187
+ return;
188
+ }
189
+
190
+ this.editor.handleInput(data);
191
+ this.refresh();
192
+ }
193
+
194
+ private handleEditorSubmit(value: string): void {
195
+ const trimmed = value.trim();
196
+ const question = this.args.controller.currentQuestion;
197
+
198
+ if (this.mode === "discuss-input") {
199
+ this.args.controller.finishDiscuss(trimmed || undefined);
200
+ this.finish();
201
+ return;
202
+ }
203
+ if (this.mode === "custom-input") {
204
+ if (trimmed.length === 0) return;
205
+ this.args.controller.setAnswer(question.id, { kind: "custom", value: trimmed });
206
+ this.advanceAfterQuestion();
207
+ return;
208
+ }
209
+ if (trimmed.length === 0) {
210
+ if (question.required) return;
211
+ this.args.controller.clearAnswer(question.id);
212
+ this.advanceAfterQuestion();
213
+ return;
214
+ }
215
+
216
+ this.args.controller.setAnswer(question.id, { kind: "text", value: trimmed });
217
+ this.advanceAfterQuestion();
218
+ }
219
+
220
+ private applyChoiceSelection(
221
+ question: NormalizedChoiceQuestion,
222
+ optionIndex: number,
223
+ submit: boolean,
224
+ ): void {
225
+ if (question.multi) {
226
+ this.toggleMultiChoice(question, optionIndex);
227
+ if (submit && this.args.controller.hasAnswer(question.id)) this.advanceAfterQuestion();
228
+ return;
229
+ }
230
+
231
+ const option = question.options[optionIndex];
232
+ if (!option) return;
233
+ this.args.controller.setAnswer(question.id, {
234
+ kind: "choice",
235
+ selections: [{ value: option.value, label: option.label }],
236
+ });
237
+ this.choiceRowIndex = optionIndex;
238
+ this.previewOptionIndex = optionIndex;
239
+ if (submit) {
240
+ this.advanceAfterQuestion();
241
+ return;
242
+ }
243
+ this.buildChoiceList(question);
244
+ this.refresh();
245
+ }
246
+
247
+ private toggleMultiChoice(question: NormalizedChoiceQuestion, optionIndex: number): void {
248
+ const existing = new Set(this.args.controller.getSelectedIndexes(question));
249
+ if (existing.has(optionIndex)) existing.delete(optionIndex);
250
+ else existing.add(optionIndex);
251
+
252
+ const selections = [...existing]
253
+ .sort((left, right) => left - right)
254
+ .flatMap((index) => {
255
+ const option = question.options[index];
256
+ return option ? [{ value: option.value, label: option.label }] : [];
257
+ });
258
+
259
+ if (selections.length > 0) {
260
+ this.args.controller.setAnswer(question.id, { kind: "choice", selections });
261
+ } else {
262
+ this.args.controller.clearAnswer(question.id);
263
+ }
264
+ this.choiceRowIndex = optionIndex;
265
+ this.previewOptionIndex = optionIndex;
266
+ this.buildChoiceList(question);
267
+ this.refresh();
268
+ }
269
+
270
+ private handleAction(action: OverlayAction): void {
271
+ switch (action) {
272
+ case "other":
273
+ this.mode = "custom-input";
274
+ this.focus = "editor";
275
+ this.editor.setText(currentCustomValue(this.args.controller));
276
+ this.refresh();
277
+ return;
278
+ case "skip":
279
+ this.args.controller.clearAnswer(this.args.controller.currentQuestion.id);
280
+ this.advanceAfterQuestion();
281
+ return;
282
+ case "discuss":
283
+ this.mode = "discuss-input";
284
+ this.focus = "editor";
285
+ this.editor.setText("");
286
+ this.refresh();
287
+ return;
288
+ case "partial":
289
+ this.args.controller.finishPartial();
290
+ this.finish();
291
+ return;
292
+ }
293
+ }
294
+
295
+ private advanceAfterQuestion(): void {
296
+ if (!this.args.controller.goNext()) {
297
+ this.args.controller.finishSubmitted();
298
+ this.finish();
299
+ return;
300
+ }
301
+ this.syncCurrentQuestion();
302
+ this.refresh();
303
+ }
304
+
305
+ private syncCurrentQuestion(): void {
306
+ const question = this.args.controller.currentQuestion;
307
+ if (question.type === "text") {
308
+ this.mode = "text";
309
+ this.focus = "editor";
310
+ this.editor.setText(currentTextValue(this.args.controller, question.initial));
311
+ this.buildTextActions();
312
+ this.choiceRows = [];
313
+ this.choiceList = undefined;
314
+ return;
315
+ }
316
+
317
+ this.mode = "choice";
318
+ this.focus = "choices";
319
+ this.textActions = [];
320
+ this.actionList = undefined;
321
+ this.editor.setText("");
322
+ this.buildChoiceList(question);
323
+ }
324
+
325
+ private buildChoiceList(question: NormalizedChoiceQuestion): void {
326
+ this.choiceRows = buildChoiceRows(this.args.controller, question);
327
+ this.choiceRowIndex = clampIndex(
328
+ defaultChoiceRowIndex(this.args.controller, question, this.choiceRows),
329
+ this.choiceRows.length,
330
+ );
331
+ this.previewOptionIndex =
332
+ previewOptionIndexForRows(this.choiceRows, this.choiceRowIndex, this.previewOptionIndex) ?? 0;
333
+
334
+ const list = new SelectList(
335
+ buildChoiceItems(this.args.controller, question, this.choiceRows),
336
+ Math.min(this.choiceRows.length, 10),
337
+ makeSelectListTheme(this.args.theme),
338
+ );
339
+ list.onSelectionChange = (item) => {
340
+ const nextIndex = this.choiceRows.findIndex((row) => choiceRowValue(row) === item.value);
341
+ if (nextIndex < 0) return;
342
+ this.choiceRowIndex = nextIndex;
343
+ this.previewOptionIndex =
344
+ previewOptionIndexForRows(this.choiceRows, nextIndex, this.previewOptionIndex) ??
345
+ this.previewOptionIndex;
346
+ this.refresh();
347
+ };
348
+ list.onSelect = (item) => {
349
+ const row = this.choiceRows.find((candidate) => choiceRowValue(candidate) === item.value);
350
+ if (!row) return;
351
+ if (row.kind === "option") {
352
+ this.applyChoiceSelection(question, row.optionIndex, true);
353
+ return;
354
+ }
355
+ this.handleAction(row.action);
356
+ };
357
+ list.setSelectedIndex(this.choiceRowIndex);
358
+ this.choiceList = list;
359
+ }
360
+
361
+ private buildTextActions(): void {
362
+ const state = createActionList({
363
+ controller: this.args.controller,
364
+ theme: this.args.theme,
365
+ actionIndex: this.actionIndex,
366
+ onIndexChange: (index) => {
367
+ this.actionIndex = index;
368
+ this.refresh();
369
+ },
370
+ onAction: (action) => this.handleAction(action),
371
+ });
372
+ this.textActions = state.entries;
373
+ this.actionList = state.list;
374
+ this.actionIndex = state.index;
375
+ }
376
+
377
+ private refresh(): void {
378
+ this.cachedLines = undefined;
379
+ this.args.tui.requestRender();
380
+ }
381
+
382
+ private finish(): void {
383
+ if (this.closed) return;
384
+ this.closed = true;
385
+ this.args.signal?.removeEventListener("abort", this.onAbort);
386
+ this.args.done(this.args.controller.outcome());
387
+ }
388
+ }
@@ -0,0 +1,35 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component, TUI } from "@earendil-works/pi-tui";
3
+ import type { AskUserController } from "../session/controller.ts";
4
+ import type { AskUserOutcome, NormalizedQuestionnaire } from "../types.ts";
5
+
6
+ export interface AskUserUiContext {
7
+ notify?(message: string, type?: "info" | "warning" | "error"): void;
8
+ custom?<T>(
9
+ factory: (
10
+ tui: TUI,
11
+ theme: Theme,
12
+ // biome-ignore lint/suspicious/noExplicitAny: keybindings are passed through by pi but unused here
13
+ keybindings: any,
14
+ done: (result: T) => void,
15
+ ) => Component & { dispose?(): void },
16
+ ): Promise<T>;
17
+ }
18
+
19
+ export interface RunQuestionnaireOptions {
20
+ ui: AskUserUiContext;
21
+ signal?: AbortSignal;
22
+ }
23
+
24
+ export interface RenderContext {
25
+ questionnaire: NormalizedQuestionnaire;
26
+ options: RunQuestionnaireOptions;
27
+ }
28
+
29
+ export interface OverlayArgs {
30
+ tui: TUI;
31
+ theme: Theme;
32
+ controller: AskUserController;
33
+ done: (result: AskUserOutcome) => void;
34
+ signal?: AbortSignal;
35
+ }
package/src/flow.ts DELETED
@@ -1,224 +0,0 @@
1
- // Shared questionnaire flow state used by the overlay UI and the
2
- // single-active-questionnaire concurrency guard. The flow owns terminal-state
3
- // transitions (`submitted`, `cancelled`, `aborted`) to keep cancellation/abort
4
- // semantics consistent.
5
-
6
- import type { Answer, NormalizedQuestion, QuestionnaireOutcome, TerminalState } from "./types.ts";
7
- import { needsReview } from "./types.ts";
8
-
9
- export type FlowMode = "answering" | "reviewing" | "terminal";
10
-
11
- export class QuestionnaireFlow {
12
- private readonly answers = new Map<string, Answer>();
13
- private index = 0;
14
- private mode: FlowMode = "answering";
15
- private terminalState: TerminalState | null = null;
16
-
17
- constructor(
18
- public readonly questions: NormalizedQuestion[],
19
- public readonly allowSkip = false,
20
- ) {
21
- if (questions.length === 0) {
22
- throw new Error("QuestionnaireFlow requires at least one question.");
23
- }
24
- }
25
-
26
- get hasOptionalQuestions(): boolean {
27
- return this.questions.some((q) => !q.required);
28
- }
29
-
30
- get showSkip(): boolean {
31
- return this.allowSkip || this.hasOptionalQuestions;
32
- }
33
-
34
- get currentIndex(): number {
35
- return this.index;
36
- }
37
-
38
- get currentMode(): FlowMode {
39
- return this.mode;
40
- }
41
-
42
- get isMultiQuestion(): boolean {
43
- return this.questions.length > 1;
44
- }
45
-
46
- get currentQuestion(): NormalizedQuestion | undefined {
47
- return this.questions[this.index];
48
- }
49
-
50
- hasAnswer(questionId: string): boolean {
51
- return this.answers.has(questionId);
52
- }
53
-
54
- getAnswer(questionId: string): Answer | undefined {
55
- return this.answers.get(questionId);
56
- }
57
-
58
- allAnswered(): boolean {
59
- return this.questions.every((q) => this.answers.has(q.id));
60
- }
61
-
62
- allRequiredAnswered(): boolean {
63
- return this.questions.every((q) => !q.required || this.answers.has(q.id));
64
- }
65
-
66
- setAnswer(answer: Answer): void {
67
- this.answers.set(answer.questionId, normalizeAnswer(answer));
68
- }
69
-
70
- advance(): boolean {
71
- if (this.mode !== "answering") return false;
72
- const current = this.currentQuestion;
73
- if (current?.required && !this.answers.has(current.id)) return false;
74
- if (this.index < this.questions.length - 1) {
75
- this.index += 1;
76
- return true;
77
- }
78
- if (needsReview(this.questions)) {
79
- this.mode = "reviewing";
80
- return true;
81
- }
82
- this.markSubmitted();
83
- return true;
84
- }
85
-
86
- goBack(): boolean {
87
- if (!this.guardNotTerminal()) return false;
88
- if (this.mode === "reviewing") {
89
- this.mode = "answering";
90
- this.index = this.questions.length - 1;
91
- return true;
92
- }
93
- if (this.index > 0) {
94
- this.index -= 1;
95
- return true;
96
- }
97
- return false;
98
- }
99
-
100
- enterReview(): boolean {
101
- if (!this.guardNotTerminal()) return false;
102
- if (!needsReview(this.questions)) return false;
103
- if (!this.allRequiredAnswered()) return false;
104
- this.mode = "reviewing";
105
- return true;
106
- }
107
-
108
- submit(): boolean {
109
- if (!this.guardNotTerminal()) return false;
110
- if (!this.allRequiredAnswered()) return false;
111
- this.markSubmitted();
112
- return true;
113
- }
114
-
115
- skip(): boolean {
116
- if (!this.guardNotTerminal()) return false;
117
- this.mode = "terminal";
118
- this.terminalState = "skipped";
119
- return true;
120
- }
121
-
122
- cancel(): void {
123
- if (!this.guardNotTerminal()) return;
124
- this.mode = "terminal";
125
- this.terminalState = "cancelled";
126
- }
127
-
128
- abort(): void {
129
- if (!this.guardNotTerminal()) return;
130
- this.mode = "terminal";
131
- this.terminalState = "aborted";
132
- }
133
-
134
- isTerminal(): boolean {
135
- return this.mode === "terminal";
136
- }
137
-
138
- outcome(): QuestionnaireOutcome {
139
- const state = this.terminalState ?? "cancelled";
140
- return {
141
- terminalState: state,
142
- answers:
143
- state === "submitted" || state === "skipped"
144
- ? this.collectAnswers()
145
- : [...this.answers.values()],
146
- ...(state === "skipped" ? { skipped: true } : {}),
147
- };
148
- }
149
-
150
- private guardNotTerminal(): boolean {
151
- return this.mode !== "terminal";
152
- }
153
-
154
- private markSubmitted(): void {
155
- this.mode = "terminal";
156
- this.terminalState = "submitted";
157
- }
158
-
159
- private collectAnswers(): Answer[] {
160
- return this.questions.flatMap((q) => {
161
- const answer = this.answers.get(q.id);
162
- return answer ? [answer] : [];
163
- });
164
- }
165
- }
166
-
167
- function normalizeAnswer(answer: Answer): Answer {
168
- switch (answer.source) {
169
- case "choice":
170
- return {
171
- questionId: answer.questionId,
172
- source: "choice",
173
- selections: answer.selections.map((selection) => ({
174
- value: selection.value.trim(),
175
- optionIndex: selection.optionIndex,
176
- note: trimOptional(selection.note),
177
- })),
178
- };
179
- case "other":
180
- return {
181
- questionId: answer.questionId,
182
- source: "other",
183
- value: answer.value.trim(),
184
- };
185
- case "discuss": {
186
- const value = trimOptional(answer.value);
187
- return value
188
- ? { questionId: answer.questionId, source: "discuss", value }
189
- : { questionId: answer.questionId, source: "discuss" };
190
- }
191
- case "text":
192
- return {
193
- questionId: answer.questionId,
194
- source: "text",
195
- value: answer.value.trim(),
196
- };
197
- }
198
- }
199
-
200
- function trimOptional(value: string | undefined): string | undefined {
201
- const trimmed = value?.trim();
202
- return trimmed && trimmed.length > 0 ? trimmed : undefined;
203
- }
204
-
205
- // Session-scoped lock: only one in-flight `ask_user` interaction at a time.
206
- // Stored at module scope per extension instance (the extension factory runs
207
- // once per session in pi).
208
- export class ActiveQuestionnaireLock {
209
- private active = false;
210
-
211
- acquire(): boolean {
212
- if (this.active) return false;
213
- this.active = true;
214
- return true;
215
- }
216
-
217
- release(): void {
218
- this.active = false;
219
- }
220
-
221
- isActive(): boolean {
222
- return this.active;
223
- }
224
- }
package/src/format.ts DELETED
@@ -1,66 +0,0 @@
1
- // Shared formatting helpers used by the overlay UI and result rendering.
2
- // Keeps summary/review formatting in one place so the overlay, transcript
3
- // renderer, and tool-content summary cannot accidentally diverge.
4
-
5
- import type { Answer, NormalizedQuestion } from "./types.ts";
6
-
7
- export const OTHER_LABEL = "Other answer";
8
- export const DISCUSS_LABEL = "Discuss instead";
9
- export const SUBMIT_SELECTIONS_LABEL = "Submit selections";
10
- export const NOTE_MARKER = "✎";
11
-
12
- export function decorateOption(label: string, recommended: boolean): string {
13
- if (!recommended) return label;
14
- if (label.trimEnd().toLowerCase().endsWith("(recommended)")) return label;
15
- return `${label} (recommended)`;
16
- }
17
-
18
- // formatSummaryBody and formatReviewLines must be kept in sync —
19
- // when adding a new answer source, update both functions.
20
- export function formatSummaryBody(question: NormalizedQuestion, answer: Answer): string {
21
- switch (answer.source) {
22
- case "choice":
23
- return answer.selections
24
- .map((selection) => {
25
- const label = question.options[selection.optionIndex]?.label ?? selection.value;
26
- return withNote(label, selection.note);
27
- })
28
- .join("; ");
29
- case "other":
30
- return `Other — ${answer.value}`;
31
- case "discuss":
32
- return answer.value ? `Discuss — ${answer.value}` : "Discuss";
33
- case "text":
34
- return answer.value;
35
- }
36
- }
37
-
38
- export function formatReviewBody(question: NormalizedQuestion, answer: Answer): string {
39
- return formatReviewLines(question, answer).join("; ");
40
- }
41
-
42
- export function formatReviewLines(question: NormalizedQuestion, answer: Answer): string[] {
43
- switch (answer.source) {
44
- case "choice":
45
- if (answer.selections.length === 0) return ["(no selections)"];
46
- return answer.selections.map((selection) => {
47
- const label = question.options[selection.optionIndex]?.label ?? selection.value;
48
- return withNote(label, selection.note);
49
- });
50
- case "other":
51
- return [`Other: ${answer.value}`];
52
- case "discuss":
53
- return [answer.value ? `Discuss: ${answer.value}` : "Discuss"];
54
- case "text":
55
- return [answer.value];
56
- }
57
- }
58
-
59
- export function formatReviewLine(question: NormalizedQuestion, answer: Answer | undefined): string {
60
- if (!answer) return "(no answer)";
61
- return formatReviewBody(question, answer);
62
- }
63
-
64
- function withNote(body: string, note: string | undefined): string {
65
- return note ? `${body} — ${note}` : body;
66
- }