@mrclrchtr/supi-ask-user 1.3.1 → 1.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 (51) hide show
  1. package/README.md +163 -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 +15 -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 +15 -13
  9. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +42 -10
  11. package/node_modules/@mrclrchtr/supi-core/src/{settings-registry.ts → settings/settings-registry.ts} +1 -1
  12. package/package.json +2 -2
  13. package/src/api.ts +19 -0
  14. package/src/ask-user.ts +71 -131
  15. package/src/index.ts +23 -1
  16. package/src/normalize.ts +153 -142
  17. package/src/render/result.ts +102 -0
  18. package/src/render/transcript.ts +65 -0
  19. package/src/render/tree-summary.ts +10 -0
  20. package/src/schema.ts +41 -38
  21. package/src/session/controller.ts +281 -0
  22. package/src/session/lock.ts +19 -0
  23. package/src/tool/guidance.ts +15 -0
  24. package/src/types.ts +56 -55
  25. package/src/ui/choose-renderer.ts +11 -0
  26. package/src/ui/overlay-actions.ts +42 -0
  27. package/src/ui/overlay-component.ts +400 -0
  28. package/src/ui/overlay-render.ts +219 -0
  29. package/src/ui/overlay-view.ts +313 -0
  30. package/src/ui/overlay.ts +28 -0
  31. package/src/ui/types.ts +38 -0
  32. package/src/flow.ts +0 -224
  33. package/src/format.ts +0 -66
  34. package/src/render/ui-rich-render-editor.ts +0 -51
  35. package/src/render/ui-rich-render-env.ts +0 -15
  36. package/src/render/ui-rich-render-footer.ts +0 -55
  37. package/src/render/ui-rich-render-markdown.ts +0 -33
  38. package/src/render/ui-rich-render-notes.ts +0 -80
  39. package/src/render/ui-rich-render-types.ts +0 -17
  40. package/src/render/ui-rich-render.ts +0 -323
  41. package/src/render.ts +0 -95
  42. package/src/result.ts +0 -90
  43. package/src/ui/ui-rich-handlers.ts +0 -369
  44. package/src/ui/ui-rich-inline.ts +0 -77
  45. package/src/ui/ui-rich-state.ts +0 -179
  46. package/src/ui/ui-rich.ts +0 -144
  47. /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
  48. /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
  49. /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
  50. /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
  51. /package/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
package/src/result.ts DELETED
@@ -1,90 +0,0 @@
1
- // Hybrid result formatting: a concise natural-language summary the model can
2
- // continue from, plus structured per-question details for transcript rendering
3
- // and future state reconstruction.
4
-
5
- import { formatSummaryBody } from "./format.ts";
6
- import type {
7
- Answer,
8
- AskUserDetails,
9
- NormalizedQuestion,
10
- QuestionnaireOutcome,
11
- TerminalState,
12
- } from "./types.ts";
13
-
14
- export interface HybridResult {
15
- content: { type: "text"; text: string }[];
16
- details: AskUserDetails;
17
- skip?: true;
18
- }
19
-
20
- export function buildResult(
21
- questions: NormalizedQuestion[],
22
- outcome: QuestionnaireOutcome,
23
- ): HybridResult {
24
- const result: HybridResult = {
25
- content: [{ type: "text", text: summarize(questions, outcome) }],
26
- details: {
27
- questions,
28
- answers: outcome.answers,
29
- answersById: indexById(questions, outcome.answers),
30
- terminalState: outcome.terminalState,
31
- },
32
- };
33
- if (outcome.skipped) result.skip = true;
34
- return result;
35
- }
36
-
37
- function indexById(
38
- questions: NormalizedQuestion[],
39
- answers: Answer[],
40
- ): Record<string, Answer | undefined> {
41
- const out: Record<string, Answer | undefined> = {};
42
- for (const question of questions) out[question.id] = undefined;
43
- for (const answer of answers) out[answer.questionId] = answer;
44
- return out;
45
- }
46
-
47
- export const ASK_USER_ERROR_MARKER = "__ask_user_error__";
48
-
49
- export function buildErrorResult(message: string): HybridResult {
50
- return {
51
- content: [{ type: "text", text: message }],
52
- details: {
53
- questions: [],
54
- answers: [],
55
- answersById: {},
56
- terminalState: "cancelled",
57
- [ASK_USER_ERROR_MARKER]: true,
58
- } as AskUserDetails & { [ASK_USER_ERROR_MARKER]: boolean },
59
- };
60
- }
61
-
62
- function summarize(questions: NormalizedQuestion[], outcome: QuestionnaireOutcome): string {
63
- if (outcome.terminalState === "skipped") {
64
- const byId = new Map(outcome.answers.map((answer) => [answer.questionId, answer]));
65
- const lines = questions.map((question) =>
66
- formatAnswerLine(question, byId.get(question.id), true),
67
- );
68
- return `User skipped the questionnaire.\n${lines.join("\n")}`;
69
- }
70
- if (outcome.terminalState !== "submitted") return summarizeTerminal(outcome.terminalState);
71
- const byId = new Map(outcome.answers.map((answer) => [answer.questionId, answer]));
72
- return questions.map((question) => formatAnswerLine(question, byId.get(question.id))).join("\n");
73
- }
74
-
75
- function summarizeTerminal(state: TerminalState): string {
76
- if (state === "cancelled") return "User cancelled the questionnaire.";
77
- return "Questionnaire was aborted before the user submitted answers.";
78
- }
79
-
80
- function formatAnswerLine(
81
- question: NormalizedQuestion,
82
- answer: Answer | undefined,
83
- skipped = false,
84
- ): string {
85
- if (!answer) {
86
- if (skipped && !question.required) return `${question.header}: (skipped)`;
87
- return `${question.header}: (no answer)`;
88
- }
89
- return `${question.header}: ${formatSummaryBody(question, answer)}`;
90
- }
@@ -1,369 +0,0 @@
1
- // Input handlers for the rich overlay questionnaire UI.
2
-
3
- import { Key, matchesKey } from "@earendil-works/pi-tui";
4
- import { currentNote, currentRowSupportsNotes } from "../render/ui-rich-render-notes.ts";
5
- import type { NormalizedQuestion, NormalizedStructuredQuestion } from "../types.ts";
6
- import {
7
- existingStructuredInputValue,
8
- interactiveRows,
9
- isEditorMode,
10
- mergedMultiNoteMap,
11
- multiNoteMapFromAnswer,
12
- type OverlayDeps,
13
- resetStateForCurrent,
14
- rowCount,
15
- selectedIndexesForQuestion,
16
- singleNoteFromAnswer,
17
- } from "./ui-rich-state.ts";
18
-
19
- export function onEditorSubmit(value: string, deps: OverlayDeps): void {
20
- const question = deps.flow.currentQuestion;
21
- if (!question) return;
22
- const trimmed = value.trim();
23
- const { state, editor, refresh } = deps;
24
- if (state.subMode === "text-input") {
25
- if (trimmed.length === 0 && question.required) return;
26
- deps.flow.setAnswer({ questionId: question.id, source: "text", value: trimmed });
27
- moveAfterAnswer(deps);
28
- return;
29
- }
30
- if (state.subMode === "other-input") {
31
- if (trimmed.length === 0) {
32
- state.subMode = "select";
33
- editor.setText("");
34
- refresh();
35
- return;
36
- }
37
- clearStructuredDrafts(question, deps);
38
- deps.flow.setAnswer({ questionId: question.id, source: "other", value: trimmed });
39
- moveAfterAnswer(deps);
40
- return;
41
- }
42
- if (state.subMode === "discuss-input") {
43
- clearStructuredDrafts(question, deps);
44
- deps.flow.setAnswer(
45
- trimmed.length > 0
46
- ? { questionId: question.id, source: "discuss", value: trimmed }
47
- : { questionId: question.id, source: "discuss" },
48
- );
49
- moveAfterAnswer(deps);
50
- return;
51
- }
52
- if (state.subMode === "note-input") {
53
- applyNoteEdit(trimmed, deps);
54
- state.subMode = "select";
55
- state.noteTarget = undefined;
56
- editor.setText("");
57
- refresh();
58
- }
59
- }
60
-
61
- function applyNoteEdit(note: string, deps: OverlayDeps): void {
62
- const { flow, state } = deps;
63
- const target = state.noteTarget;
64
- if (!target) return;
65
- if (target.mode === "single") {
66
- if (note.length > 0) state.stagedSingleNotes.set(target.questionId, note);
67
- else state.stagedSingleNotes.delete(target.questionId);
68
- return;
69
- }
70
- const existing = new Map(
71
- state.stagedMultiNotes.get(target.questionId) ??
72
- multiNoteMapFromAnswer(flow, target.questionId),
73
- );
74
- if (note.length > 0) existing.set(target.optionIndex, note);
75
- else existing.delete(target.optionIndex);
76
- if (existing.size > 0) state.stagedMultiNotes.set(target.questionId, existing);
77
- else state.stagedMultiNotes.delete(target.questionId);
78
- }
79
-
80
- function clearStructuredDrafts(question: NormalizedQuestion, deps: OverlayDeps): void {
81
- if (question.type === "text" || !question.multi) return;
82
- deps.state.stagedSelections.delete(question.id);
83
- deps.state.stagedMultiNotes.delete(question.id);
84
- }
85
-
86
- function handleInlineEditorNav(data: string, deps: OverlayDeps): boolean {
87
- const { state, flow } = deps;
88
- if (
89
- (state.subMode !== "other-input" && state.subMode !== "discuss-input") ||
90
- (!matchesKey(data, Key.up) && !matchesKey(data, Key.down))
91
- ) {
92
- return false;
93
- }
94
- const question = flow.currentQuestion;
95
- if (!question || question.type === "text") return false;
96
- const maxIndex = Math.max(0, rowCount(question) - 1);
97
- const nextIndex = matchesKey(data, Key.up)
98
- ? Math.max(0, state.selectedIndex - 1)
99
- : Math.min(maxIndex, state.selectedIndex + 1);
100
- state.subMode = "select";
101
- deps.editor.setText("");
102
- moveSelection(question, nextIndex, deps);
103
- return true;
104
- }
105
-
106
- export function handleOverlayInput(data: string, deps: OverlayDeps): void {
107
- const { state, flow } = deps;
108
- if (isEditorMode(state.subMode)) {
109
- if (state.subMode === "text-input" && matchesKey(data, Key.ctrl("s")) && flow.showSkip) {
110
- handleSkipAction(deps);
111
- return;
112
- }
113
- if (matchesKey(data, Key.escape)) {
114
- handleEditorEscape(deps);
115
- return;
116
- }
117
- if (handleInlineEditorNav(data, deps)) return;
118
- deps.editor.handleInput(data);
119
- deps.refresh();
120
- return;
121
- }
122
- if (matchesKey(data, Key.escape)) {
123
- flow.cancel();
124
- deps.finish(flow.outcome());
125
- return;
126
- }
127
- if (flow.currentMode === "reviewing") {
128
- handleReviewInput(data, deps);
129
- return;
130
- }
131
- handleSelectInput(data, deps);
132
- }
133
-
134
- function handleEditorEscape(deps: OverlayDeps): void {
135
- const { state, editor, flow, refresh } = deps;
136
- if (state.subMode === "text-input") {
137
- const question = flow.currentQuestion;
138
- if (question && !question.required) {
139
- flow.advance();
140
- if (flow.isTerminal()) {
141
- deps.finish(flow.outcome());
142
- return;
143
- }
144
- resetStateForCurrent(deps);
145
- refresh();
146
- return;
147
- }
148
- flow.cancel();
149
- deps.finish(flow.outcome());
150
- return;
151
- }
152
- state.subMode = "select";
153
- state.noteTarget = undefined;
154
- editor.setText("");
155
- refresh();
156
- }
157
-
158
- function handleSelectInput(data: string, deps: OverlayDeps): void {
159
- const { flow, state } = deps;
160
- const question = flow.currentQuestion;
161
- if (!question || question.type === "text") return;
162
- const maxIndex = Math.max(0, rowCount(question) - 1);
163
- if (matchesKey(data, Key.up)) {
164
- moveSelection(question, Math.max(0, state.selectedIndex - 1), deps);
165
- return;
166
- }
167
- if (matchesKey(data, Key.down)) {
168
- moveSelection(question, Math.min(maxIndex, state.selectedIndex + 1), deps);
169
- return;
170
- }
171
- if (handleSelectNav(data, deps)) return;
172
- if (question.multi && matchesKey(data, Key.space)) {
173
- toggleCurrentSelection(question, deps);
174
- return;
175
- }
176
- if (matchesKey(data, "n") && currentRowSupportsNotes(question, state)) {
177
- openNoteEditor(question, deps);
178
- return;
179
- }
180
- if (matchesKey(data, Key.enter)) {
181
- handleSelectEnter(question, deps);
182
- return;
183
- }
184
- if (matchesKey(data, "s") && flow.showSkip) {
185
- handleSkipAction(deps);
186
- return;
187
- }
188
- }
189
-
190
- /** Skip action shared by Ctrl-S (text-input) and 's' (select) handlers. */
191
- function handleSkipAction(deps: OverlayDeps): void {
192
- const { flow } = deps;
193
- const question = flow.currentQuestion;
194
- if (flow.isMultiQuestion && question && !question.required && !flow.hasAnswer(question.id)) {
195
- flow.advance();
196
- if (flow.isTerminal()) {
197
- deps.finish(flow.outcome());
198
- return;
199
- }
200
- resetStateForCurrent(deps);
201
- deps.refresh();
202
- return;
203
- }
204
- flow.skip();
205
- deps.finish(flow.outcome());
206
- }
207
-
208
- function openNoteEditor(question: NormalizedQuestion, deps: OverlayDeps): void {
209
- if (question.type === "text") return;
210
- const rows = interactiveRows(question);
211
- const row = rows[deps.state.selectedIndex];
212
- if (!row || row.kind !== "option") return;
213
- deps.state.subMode = "note-input";
214
- deps.state.noteTarget = question.multi
215
- ? { mode: "multi", questionId: question.id, optionIndex: row.optionIndex }
216
- : { mode: "single", questionId: question.id };
217
- deps.editor.setText(currentNote(deps.flow, deps.state, question) ?? "");
218
- deps.refresh();
219
- }
220
-
221
- function handleSelectNav(data: string, deps: OverlayDeps): boolean {
222
- const { flow, refresh } = deps;
223
- if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
224
- if (flow.goBack()) {
225
- resetStateForCurrent(deps);
226
- refresh();
227
- }
228
- return true;
229
- }
230
- if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
231
- const question = flow.currentQuestion;
232
- if (flow.allRequiredAnswered() && flow.enterReview()) {
233
- refresh();
234
- } else if (question && !question.required && !flow.hasAnswer(question.id) && flow.advance()) {
235
- resetStateForCurrent(deps);
236
- refresh();
237
- }
238
- return true;
239
- }
240
- return false;
241
- }
242
-
243
- function handleSelectEnter(question: NormalizedQuestion, deps: OverlayDeps): void {
244
- if (question.type === "text") return;
245
- const row = interactiveRows(question)[deps.state.selectedIndex];
246
- if (!row) return;
247
- if (row.kind === "option") {
248
- if (question.multi) {
249
- handleSubmitSelections(question, deps);
250
- return;
251
- }
252
- handleOptionRow(question, row.optionIndex, deps);
253
- return;
254
- }
255
- openStructuredInput(question, row.kind, deps);
256
- }
257
-
258
- function handleOptionRow(
259
- question: NormalizedStructuredQuestion,
260
- optionIndex: number,
261
- deps: OverlayDeps,
262
- ): void {
263
- const option = question.options[optionIndex];
264
- const note =
265
- deps.state.stagedSingleNotes.get(question.id) ?? singleNoteFromAnswer(deps.flow, question.id);
266
- deps.flow.setAnswer({
267
- questionId: question.id,
268
- source: "choice",
269
- selections: [{ value: option.value, optionIndex, note }],
270
- });
271
- moveAfterAnswer(deps);
272
- }
273
-
274
- function toggleCurrentSelection(question: NormalizedStructuredQuestion, deps: OverlayDeps): void {
275
- const row = interactiveRows(question)[deps.state.selectedIndex];
276
- if (!row || row.kind !== "option") return;
277
- toggleSelection(question, row.optionIndex, deps);
278
- deps.refresh();
279
- }
280
-
281
- function openStructuredInput(
282
- question: NormalizedStructuredQuestion,
283
- kind: "other" | "discuss",
284
- deps: OverlayDeps,
285
- ): void {
286
- deps.state.subMode = kind === "other" ? "other-input" : "discuss-input";
287
- deps.editor.setText(existingStructuredInputValue(deps.flow, question.id, kind));
288
- deps.refresh();
289
- }
290
-
291
- function moveSelection(question: NormalizedQuestion, nextIndex: number, deps: OverlayDeps): void {
292
- const { state, refresh } = deps;
293
- if (state.selectedIndex === nextIndex) {
294
- refresh();
295
- return;
296
- }
297
- state.selectedIndex = nextIndex;
298
- if (question.type === "text") {
299
- refresh();
300
- return;
301
- }
302
- const row = interactiveRows(question)[nextIndex];
303
- if (row?.kind === "other" || row?.kind === "discuss") {
304
- openStructuredInput(question, row.kind, deps);
305
- return;
306
- }
307
- refresh();
308
- }
309
-
310
- function toggleSelection(
311
- question: NormalizedStructuredQuestion,
312
- optionIndex: number,
313
- deps: OverlayDeps,
314
- ): void {
315
- const existing = new Set(selectedIndexesForQuestion(deps.flow, deps.state, question));
316
- if (existing.has(optionIndex)) existing.delete(optionIndex);
317
- else existing.add(optionIndex);
318
- deps.state.stagedSelections.set(
319
- question.id,
320
- [...existing].sort((a, b) => a - b),
321
- );
322
- }
323
-
324
- function handleSubmitSelections(question: NormalizedStructuredQuestion, deps: OverlayDeps): void {
325
- if (!question.multi) return;
326
- const indexes = selectedIndexesForQuestion(deps.flow, deps.state, question);
327
- if (indexes.length === 0) return;
328
- const noteMap = mergedMultiNoteMap(deps, question.id);
329
- const selections = indexes.map((optionIndex) => ({
330
- value: question.options[optionIndex].value,
331
- optionIndex,
332
- note: noteMap.get(optionIndex),
333
- }));
334
- deps.flow.setAnswer({
335
- questionId: question.id,
336
- source: "choice",
337
- selections,
338
- });
339
- moveAfterAnswer(deps);
340
- }
341
-
342
- function handleReviewInput(data: string, deps: OverlayDeps): void {
343
- const { flow, refresh } = deps;
344
- if (matchesKey(data, Key.enter)) {
345
- if (flow.submit()) deps.finish(flow.outcome());
346
- return;
347
- }
348
- if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left) || matchesKey(data, "b")) {
349
- if (flow.goBack()) {
350
- resetStateForCurrent(deps);
351
- refresh();
352
- }
353
- return;
354
- }
355
- if (matchesKey(data, "s") && flow.showSkip) {
356
- flow.skip();
357
- deps.finish(flow.outcome());
358
- }
359
- }
360
-
361
- export function moveAfterAnswer(deps: OverlayDeps): void {
362
- deps.flow.advance();
363
- if (deps.flow.isTerminal()) {
364
- deps.finish(deps.flow.outcome());
365
- return;
366
- }
367
- resetStateForCurrent(deps);
368
- deps.refresh();
369
- }
@@ -1,77 +0,0 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import type { Editor } from "@earendil-works/pi-tui";
3
- import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
4
- import type { QuestionnaireFlow } from "../flow.ts";
5
- import { DISCUSS_LABEL, OTHER_LABEL } from "../format.ts";
6
- import type { OverlayRenderState } from "../render/ui-rich-render-types.ts";
7
- import type { NormalizedStructuredQuestion } from "../types.ts";
8
- import type { InteractiveRow } from "./ui-rich-state.ts";
9
-
10
- export function structuredRowLabel(
11
- flow: Pick<QuestionnaireFlow, "getAnswer">,
12
- question: NormalizedStructuredQuestion,
13
- row: Extract<InteractiveRow, { kind: "other" | "discuss" }>,
14
- ): string {
15
- const label = row.kind === "other" ? OTHER_LABEL : DISCUSS_LABEL;
16
- const answer = flow.getAnswer(question.id);
17
- if (row.kind === "other" && answer?.source === "other") return `${label}: ${answer.value}`;
18
- if (row.kind === "discuss" && answer?.source === "discuss" && answer.value) {
19
- return `${label}: ${answer.value}`;
20
- }
21
- return label;
22
- }
23
-
24
- export function inlineStructuredRowLines(args: {
25
- width: number;
26
- theme: Theme;
27
- state: OverlayRenderState;
28
- editor: Editor;
29
- row: InteractiveRow;
30
- prefix: string;
31
- }): string[] | undefined {
32
- const { width, theme, state, editor, row, prefix } = args;
33
- if (!isInlineStructuredInput(state, row)) return undefined;
34
- const label = row.kind === "other" ? OTHER_LABEL : DISCUSS_LABEL;
35
- return renderInlineEditorLines(width - visibleWidth(prefix), theme, editor, `${label}: `);
36
- }
37
-
38
- function isInlineStructuredInput(state: OverlayRenderState, row: InteractiveRow): boolean {
39
- return (
40
- (state.subMode === "other-input" && row.kind === "other") ||
41
- (state.subMode === "discuss-input" && row.kind === "discuss")
42
- );
43
- }
44
-
45
- function renderInlineEditorLines(
46
- width: number,
47
- theme: Theme,
48
- editor: Editor,
49
- prefix = "",
50
- ): string[] {
51
- const lines = editor.getLines();
52
- const cursor = editor.getCursor();
53
- const out: string[] = [];
54
- const lineWidth = Math.max(1, width);
55
- const prefixWidth = visibleWidth(prefix);
56
- const contentWidth = Math.max(1, lineWidth - prefixWidth);
57
- const continuationPrefix = " ".repeat(prefixWidth);
58
-
59
- for (const [index, line] of lines.entries()) {
60
- const content = index === cursor.line ? highlightCursor(theme, line, cursor.col) : line;
61
- const wrapped = wrapTextWithAnsi(content, contentWidth);
62
- for (const [chunkIndex, chunk] of wrapped.entries()) {
63
- out.push(
64
- truncateToWidth(`${chunkIndex === 0 ? prefix : continuationPrefix}${chunk}`, lineWidth),
65
- );
66
- }
67
- }
68
-
69
- return out.length > 0 ? out : [truncateToWidth(prefix, lineWidth)];
70
- }
71
-
72
- function highlightCursor(theme: Theme, line: string, col: number): string {
73
- const before = line.slice(0, col);
74
- const current = line.slice(col, col + 1) || " ";
75
- const after = line.slice(col + 1);
76
- return `${before}${theme.bg("selectedBg", theme.fg("text", current))}${after}`;
77
- }