@mrclrchtr/supi-ask-user 0.1.0 → 0.2.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 (43) hide show
  1. package/README.md +115 -0
  2. package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  18. package/package.json +14 -7
  19. package/src/ask-user.ts +191 -0
  20. package/{flow.ts → src/flow.ts} +45 -33
  21. package/src/format.ts +66 -0
  22. package/src/index.ts +1 -0
  23. package/src/normalize.ts +229 -0
  24. package/{ui-rich-render-editor.ts → src/render/ui-rich-render-editor.ts} +18 -16
  25. package/src/render/ui-rich-render-env.ts +15 -0
  26. package/src/render/ui-rich-render-footer.ts +55 -0
  27. package/src/render/ui-rich-render-markdown.ts +33 -0
  28. package/{ui-rich-render-notes.ts → src/render/ui-rich-render-notes.ts} +20 -27
  29. package/src/render/ui-rich-render-types.ts +17 -0
  30. package/src/render/ui-rich-render.ts +323 -0
  31. package/{render.ts → src/render.ts} +10 -5
  32. package/{result.ts → src/result.ts} +27 -6
  33. package/{schema.ts → src/schema.ts} +46 -44
  34. package/{types.ts → src/types.ts} +20 -53
  35. package/{ui-rich-handlers.ts → src/ui/ui-rich-handlers.ts} +100 -44
  36. package/{ui-rich-inline.ts → src/ui/ui-rich-inline.ts} +23 -10
  37. package/{ui-rich-state.ts → src/ui/ui-rich-state.ts} +52 -15
  38. package/{ui-rich.ts → src/ui/ui-rich.ts} +36 -11
  39. package/ask-user.ts +0 -131
  40. package/format.ts +0 -95
  41. package/normalize.ts +0 -218
  42. package/ui-fallback.ts +0 -274
  43. package/ui-rich-render.ts +0 -370
@@ -2,9 +2,9 @@
2
2
  // The external (model-facing) schema lives in `schema.ts`; everything beyond
3
3
  // parsing passes through normalization into the shapes defined here.
4
4
 
5
- export type QuestionType = "choice" | "multichoice" | "text" | "yesno";
5
+ export type QuestionType = "choice" | "text";
6
6
 
7
- export type TerminalState = "submitted" | "cancelled" | "aborted";
7
+ export type TerminalState = "submitted" | "cancelled" | "aborted" | "skipped";
8
8
 
9
9
  export interface NormalizedOption {
10
10
  value: string;
@@ -18,6 +18,7 @@ interface BaseQuestion {
18
18
  header: string;
19
19
  prompt: string;
20
20
  type: QuestionType;
21
+ required: boolean;
21
22
  }
22
23
 
23
24
  interface StructuredQuestionBase extends BaseQuestion {
@@ -25,60 +26,39 @@ interface StructuredQuestionBase extends BaseQuestion {
25
26
  allowOther: boolean;
26
27
  allowDiscuss: boolean;
27
28
  recommendedIndexes: number[];
29
+ defaultIndexes: number[];
30
+ multi: boolean;
28
31
  }
29
32
 
30
33
  export interface NormalizedChoiceQuestion extends StructuredQuestionBase {
31
34
  type: "choice";
32
35
  }
33
36
 
34
- export interface NormalizedMultiChoiceQuestion extends StructuredQuestionBase {
35
- type: "multichoice";
36
- }
37
-
38
- export interface NormalizedYesNoQuestion extends StructuredQuestionBase {
39
- type: "yesno";
40
- }
41
-
42
37
  export interface NormalizedTextQuestion extends BaseQuestion {
43
38
  type: "text";
44
39
  options: [];
40
+ default?: string;
45
41
  }
46
42
 
47
- export type NormalizedStructuredQuestion =
48
- | NormalizedChoiceQuestion
49
- | NormalizedMultiChoiceQuestion
50
- | NormalizedYesNoQuestion;
43
+ export type NormalizedStructuredQuestion = NormalizedChoiceQuestion;
51
44
 
52
- export type NormalizedQuestion =
53
- | NormalizedChoiceQuestion
54
- | NormalizedMultiChoiceQuestion
55
- | NormalizedTextQuestion
56
- | NormalizedYesNoQuestion;
45
+ export type NormalizedQuestion = NormalizedChoiceQuestion | NormalizedTextQuestion;
57
46
 
58
47
  export interface NormalizedQuestionnaire {
59
48
  questions: NormalizedQuestion[];
49
+ allowSkip: boolean;
60
50
  }
61
51
 
62
- export interface OptionAnswer {
63
- questionId: string;
64
- source: "option";
65
- value: string;
66
- optionIndex: number;
67
- note?: string;
68
- }
69
-
70
- export interface MultiSelection {
52
+ export interface Selection {
71
53
  value: string;
72
54
  optionIndex: number;
73
55
  note?: string;
74
56
  }
75
57
 
76
- export interface OptionsAnswer {
58
+ export interface ChoiceAnswer {
77
59
  questionId: string;
78
- source: "options";
79
- values: string[];
80
- optionIndexes: number[];
81
- selections: MultiSelection[];
60
+ source: "choice";
61
+ selections: Selection[];
82
62
  }
83
63
 
84
64
  export interface OtherAnswer {
@@ -99,41 +79,28 @@ export interface TextAnswer {
99
79
  value: string;
100
80
  }
101
81
 
102
- export interface YesNoAnswer {
103
- questionId: string;
104
- source: "yesno";
105
- value: "yes" | "no";
106
- optionIndex: 0 | 1;
107
- note?: string;
108
- }
109
-
110
- export type Answer =
111
- | OptionAnswer
112
- | OptionsAnswer
113
- | OtherAnswer
114
- | DiscussAnswer
115
- | TextAnswer
116
- | YesNoAnswer;
82
+ export type Answer = ChoiceAnswer | OtherAnswer | DiscussAnswer | TextAnswer;
117
83
 
118
84
  export interface QuestionnaireOutcome {
119
85
  terminalState: TerminalState;
120
86
  answers: Answer[];
87
+ skipped?: true;
121
88
  }
122
89
 
123
90
  export interface AskUserDetails {
124
91
  questions: NormalizedQuestion[];
125
92
  answers: Answer[];
126
- answersById: Record<string, Answer>;
93
+ answersById: Record<string, Answer | undefined>;
127
94
  terminalState: TerminalState;
128
95
  }
129
96
 
130
97
  export const QUESTION_LIMITS = {
131
98
  minQuestions: 1,
132
99
  maxQuestions: 4,
133
- maxHeaderLength: 40,
134
- maxPromptLength: 2000,
100
+ maxHeaderLength: 60,
101
+ maxPromptLength: 4000,
135
102
  minChoiceOptions: 2,
136
- maxChoiceOptions: 8,
103
+ maxChoiceOptions: 12,
137
104
  } as const;
138
105
 
139
106
  export function isStructuredQuestion(
@@ -143,7 +110,7 @@ export function isStructuredQuestion(
143
110
  }
144
111
 
145
112
  export function needsReview(questions: NormalizedQuestion[]): boolean {
146
- return questions.length > 1 || questions.some((q) => q.type === "multichoice");
113
+ return questions.length > 1 || questions.some((q) => q.type !== "text" && q.multi);
147
114
  }
148
115
 
149
116
  export function primaryRecommendationIndex(question: NormalizedQuestion): number | undefined {
@@ -1,17 +1,18 @@
1
1
  // Input handlers for the rich overlay questionnaire UI.
2
2
 
3
- import { Key, matchesKey } from "@mariozechner/pi-tui";
4
- import type { NormalizedQuestion, NormalizedStructuredQuestion } from "./types.ts";
5
- import { isEditorMode, selectedIndexesForQuestion } from "./ui-rich-render.ts";
6
- import { currentNote, currentRowSupportsNotes } from "./ui-rich-render-notes.ts";
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";
7
6
  import {
8
7
  existingStructuredInputValue,
9
8
  interactiveRows,
9
+ isEditorMode,
10
10
  mergedMultiNoteMap,
11
11
  multiNoteMapFromAnswer,
12
12
  type OverlayDeps,
13
13
  resetStateForCurrent,
14
14
  rowCount,
15
+ selectedIndexesForQuestion,
15
16
  singleNoteFromAnswer,
16
17
  } from "./ui-rich-state.ts";
17
18
 
@@ -21,7 +22,7 @@ export function onEditorSubmit(value: string, deps: OverlayDeps): void {
21
22
  const trimmed = value.trim();
22
23
  const { state, editor, refresh } = deps;
23
24
  if (state.subMode === "text-input") {
24
- if (trimmed.length === 0) return;
25
+ if (trimmed.length === 0 && question.required) return;
25
26
  deps.flow.setAnswer({ questionId: question.id, source: "text", value: trimmed });
26
27
  moveAfterAnswer(deps);
27
28
  return;
@@ -33,11 +34,13 @@ export function onEditorSubmit(value: string, deps: OverlayDeps): void {
33
34
  refresh();
34
35
  return;
35
36
  }
37
+ clearStructuredDrafts(question, deps);
36
38
  deps.flow.setAnswer({ questionId: question.id, source: "other", value: trimmed });
37
39
  moveAfterAnswer(deps);
38
40
  return;
39
41
  }
40
42
  if (state.subMode === "discuss-input") {
43
+ clearStructuredDrafts(question, deps);
41
44
  deps.flow.setAnswer(
42
45
  trimmed.length > 0
43
46
  ? { questionId: question.id, source: "discuss", value: trimmed }
@@ -74,13 +77,44 @@ function applyNoteEdit(note: string, deps: OverlayDeps): void {
74
77
  else state.stagedMultiNotes.delete(target.questionId);
75
78
  }
76
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
+
77
106
  export function handleOverlayInput(data: string, deps: OverlayDeps): void {
78
107
  const { state, flow } = deps;
79
108
  if (isEditorMode(state.subMode)) {
109
+ if (state.subMode === "text-input" && matchesKey(data, Key.ctrl("s")) && flow.showSkip) {
110
+ handleSkipAction(deps);
111
+ return;
112
+ }
80
113
  if (matchesKey(data, Key.escape)) {
81
114
  handleEditorEscape(deps);
82
115
  return;
83
116
  }
117
+ if (handleInlineEditorNav(data, deps)) return;
84
118
  deps.editor.handleInput(data);
85
119
  deps.refresh();
86
120
  return;
@@ -100,6 +134,17 @@ export function handleOverlayInput(data: string, deps: OverlayDeps): void {
100
134
  function handleEditorEscape(deps: OverlayDeps): void {
101
135
  const { state, editor, flow, refresh } = deps;
102
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
+ }
103
148
  flow.cancel();
104
149
  deps.finish(flow.outcome());
105
150
  return;
@@ -113,7 +158,7 @@ function handleEditorEscape(deps: OverlayDeps): void {
113
158
  function handleSelectInput(data: string, deps: OverlayDeps): void {
114
159
  const { flow, state } = deps;
115
160
  const question = flow.currentQuestion;
116
- if (!question) return;
161
+ if (!question || question.type === "text") return;
117
162
  const maxIndex = Math.max(0, rowCount(question) - 1);
118
163
  if (matchesKey(data, Key.up)) {
119
164
  moveSelection(question, Math.max(0, state.selectedIndex - 1), deps);
@@ -124,7 +169,7 @@ function handleSelectInput(data: string, deps: OverlayDeps): void {
124
169
  return;
125
170
  }
126
171
  if (handleSelectNav(data, deps)) return;
127
- if (question.type === "multichoice" && matchesKey(data, Key.space)) {
172
+ if (question.multi && matchesKey(data, Key.space)) {
128
173
  toggleCurrentSelection(question, deps);
129
174
  return;
130
175
  }
@@ -136,17 +181,39 @@ function handleSelectInput(data: string, deps: OverlayDeps): void {
136
181
  handleSelectEnter(question, deps);
137
182
  return;
138
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());
139
206
  }
140
207
 
141
208
  function openNoteEditor(question: NormalizedQuestion, deps: OverlayDeps): void {
142
209
  if (question.type === "text") return;
143
- const row = interactiveRows(question)[deps.state.selectedIndex];
210
+ const rows = interactiveRows(question);
211
+ const row = rows[deps.state.selectedIndex];
144
212
  if (!row || row.kind !== "option") return;
145
213
  deps.state.subMode = "note-input";
146
- deps.state.noteTarget =
147
- question.type === "multichoice"
148
- ? { mode: "multi", questionId: question.id, optionIndex: row.optionIndex }
149
- : { mode: "single", questionId: question.id };
214
+ deps.state.noteTarget = question.multi
215
+ ? { mode: "multi", questionId: question.id, optionIndex: row.optionIndex }
216
+ : { mode: "single", questionId: question.id };
150
217
  deps.editor.setText(currentNote(deps.flow, deps.state, question) ?? "");
151
218
  deps.refresh();
152
219
  }
@@ -161,7 +228,13 @@ function handleSelectNav(data: string, deps: OverlayDeps): boolean {
161
228
  return true;
162
229
  }
163
230
  if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
164
- if (flow.allAnswered() && flow.enterReview()) refresh();
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
+ }
165
238
  return true;
166
239
  }
167
240
  return false;
@@ -172,7 +245,7 @@ function handleSelectEnter(question: NormalizedQuestion, deps: OverlayDeps): voi
172
245
  const row = interactiveRows(question)[deps.state.selectedIndex];
173
246
  if (!row) return;
174
247
  if (row.kind === "option") {
175
- if (question.type === "multichoice") {
248
+ if (question.multi) {
176
249
  handleSubmitSelections(question, deps);
177
250
  return;
178
251
  }
@@ -187,38 +260,18 @@ function handleOptionRow(
187
260
  optionIndex: number,
188
261
  deps: OverlayDeps,
189
262
  ): void {
190
- if (question.type === "multichoice") {
191
- toggleSelection(question, optionIndex, deps);
192
- deps.refresh();
193
- return;
194
- }
195
263
  const option = question.options[optionIndex];
196
264
  const note =
197
265
  deps.state.stagedSingleNotes.get(question.id) ?? singleNoteFromAnswer(deps.flow, question.id);
198
- deps.flow.setAnswer(
199
- question.type === "yesno"
200
- ? {
201
- questionId: question.id,
202
- source: "yesno",
203
- value: option.value as "yes" | "no",
204
- optionIndex: optionIndex as 0 | 1,
205
- note,
206
- }
207
- : {
208
- questionId: question.id,
209
- source: "option",
210
- value: option.value,
211
- optionIndex,
212
- note,
213
- },
214
- );
266
+ deps.flow.setAnswer({
267
+ questionId: question.id,
268
+ source: "choice",
269
+ selections: [{ value: option.value, optionIndex, note }],
270
+ });
215
271
  moveAfterAnswer(deps);
216
272
  }
217
273
 
218
- function toggleCurrentSelection(
219
- question: Extract<NormalizedStructuredQuestion, { type: "multichoice" }>,
220
- deps: OverlayDeps,
221
- ): void {
274
+ function toggleCurrentSelection(question: NormalizedStructuredQuestion, deps: OverlayDeps): void {
222
275
  const row = interactiveRows(question)[deps.state.selectedIndex];
223
276
  if (!row || row.kind !== "option") return;
224
277
  toggleSelection(question, row.optionIndex, deps);
@@ -269,7 +322,7 @@ function toggleSelection(
269
322
  }
270
323
 
271
324
  function handleSubmitSelections(question: NormalizedStructuredQuestion, deps: OverlayDeps): void {
272
- if (question.type !== "multichoice") return;
325
+ if (!question.multi) return;
273
326
  const indexes = selectedIndexesForQuestion(deps.flow, deps.state, question);
274
327
  if (indexes.length === 0) return;
275
328
  const noteMap = mergedMultiNoteMap(deps, question.id);
@@ -280,9 +333,7 @@ function handleSubmitSelections(question: NormalizedStructuredQuestion, deps: Ov
280
333
  }));
281
334
  deps.flow.setAnswer({
282
335
  questionId: question.id,
283
- source: "options",
284
- values: selections.map((s) => s.value),
285
- optionIndexes: selections.map((s) => s.optionIndex),
336
+ source: "choice",
286
337
  selections,
287
338
  });
288
339
  moveAfterAnswer(deps);
@@ -299,6 +350,11 @@ function handleReviewInput(data: string, deps: OverlayDeps): void {
299
350
  resetStateForCurrent(deps);
300
351
  refresh();
301
352
  }
353
+ return;
354
+ }
355
+ if (matchesKey(data, "s") && flow.showSkip) {
356
+ flow.skip();
357
+ deps.finish(flow.outcome());
302
358
  }
303
359
  }
304
360
 
@@ -1,10 +1,10 @@
1
- import type { Theme } from "@mariozechner/pi-coding-agent";
2
- import type { Editor } from "@mariozechner/pi-tui";
3
- import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
4
- import type { QuestionnaireFlow } from "./flow.ts";
5
- import { DISCUSS_LABEL, OTHER_LABEL } from "./format.ts";
6
- import type { NormalizedStructuredQuestion } from "./types.ts";
7
- import type { OverlayRenderState } from "./ui-rich-render.ts";
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
8
  import type { InteractiveRow } from "./ui-rich-state.ts";
9
9
 
10
10
  export function structuredRowLabel(
@@ -50,10 +50,23 @@ function renderInlineEditorLines(
50
50
  ): string[] {
51
51
  const lines = editor.getLines();
52
52
  const cursor = editor.getCursor();
53
- return lines.map((line, index) => {
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()) {
54
60
  const content = index === cursor.line ? highlightCursor(theme, line, cursor.col) : line;
55
- return truncateToWidth(`${prefix}${content}`, Math.max(1, width));
56
- });
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)];
57
70
  }
58
71
 
59
72
  function highlightCursor(theme: Theme, line: string, col: number): string {
@@ -1,14 +1,15 @@
1
1
  // Shared state types and pure helpers for the rich overlay.
2
2
 
3
- import type { Editor } from "@mariozechner/pi-tui";
4
- import type { QuestionnaireFlow } from "./flow.ts";
3
+ import type { Editor } from "@earendil-works/pi-tui";
4
+ import type { QuestionnaireFlow } from "../flow.ts";
5
+ import type { OverlayRenderState, SubMode } from "../render/ui-rich-render-types.ts";
5
6
  import type {
6
7
  NormalizedQuestion,
7
8
  NormalizedStructuredQuestion,
9
+ NormalizedTextQuestion,
8
10
  QuestionnaireOutcome,
9
- } from "./types.ts";
10
- import { isStructuredQuestion, primaryRecommendationIndex } from "./types.ts";
11
- import type { OverlayRenderState, SubMode } from "./ui-rich-render.ts";
11
+ } from "../types.ts";
12
+ import { isStructuredQuestion, primaryRecommendationIndex } from "../types.ts";
12
13
 
13
14
  export interface NoteTargetSingle {
14
15
  mode: "single";
@@ -27,6 +28,7 @@ export interface OverlayState extends OverlayRenderState {
27
28
  noteTarget?: NoteTarget;
28
29
  cachedLines: string[] | undefined;
29
30
  cachedWidth: number | undefined;
31
+ maxHeight: number;
30
32
  }
31
33
 
32
34
  export interface OverlayDeps {
@@ -65,12 +67,23 @@ export function initialSubMode(question: NormalizedQuestion | undefined): SubMod
65
67
  return question.type === "text" ? "text-input" : "select";
66
68
  }
67
69
 
70
+ export function textDefaultOrAnswer(
71
+ flow: Pick<QuestionnaireFlow, "getAnswer">,
72
+ question: NormalizedTextQuestion,
73
+ ): string {
74
+ const answer = flow.getAnswer(question.id);
75
+ if (answer?.source === "text") return answer.value;
76
+ return question.default ?? "";
77
+ }
78
+
68
79
  export function resetStateForCurrent(deps: OverlayDeps): void {
69
80
  const question = deps.flow.currentQuestion;
70
81
  deps.state.subMode = deps.flow.currentMode === "reviewing" ? "select" : initialSubMode(question);
71
82
  deps.state.selectedIndex = selectedRowIndex(deps.flow, question);
72
83
  deps.state.noteTarget = undefined;
73
- deps.editor.setText("");
84
+ deps.state.maxHeight = 0;
85
+ const editorText = question?.type === "text" ? textDefaultOrAnswer(deps.flow, question) : "";
86
+ deps.editor.setText(editorText);
74
87
  }
75
88
 
76
89
  export function existingStructuredInputValue(
@@ -88,9 +101,8 @@ export function singleNoteFromAnswer(
88
101
  questionId: string,
89
102
  ): string | undefined {
90
103
  const answer = flow.getAnswer(questionId);
91
- if (!answer) return undefined;
92
- if (answer.source === "option" || answer.source === "yesno") return answer.note;
93
- return undefined;
104
+ if (!answer || answer.source !== "choice") return undefined;
105
+ return answer.selections[0]?.note;
94
106
  }
95
107
 
96
108
  export function multiNoteMapFromAnswer(
@@ -99,7 +111,7 @@ export function multiNoteMapFromAnswer(
99
111
  ): Map<number, string> {
100
112
  const answer = flow.getAnswer(questionId);
101
113
  const map = new Map<number, string>();
102
- if (!answer || answer.source !== "options") return map;
114
+ if (!answer || answer.source !== "choice") return map;
103
115
  for (const selection of answer.selections) {
104
116
  if (selection.note) map.set(selection.optionIndex, selection.note);
105
117
  }
@@ -115,6 +127,32 @@ export function mergedMultiNoteMap(deps: OverlayDeps, questionId: string): Map<n
115
127
  return merged;
116
128
  }
117
129
 
130
+ export function isEditorMode(mode: SubMode): boolean {
131
+ return (
132
+ mode === "text-input" ||
133
+ mode === "other-input" ||
134
+ mode === "discuss-input" ||
135
+ mode === "note-input"
136
+ );
137
+ }
138
+
139
+ export function selectedIndexesForQuestion(
140
+ flow: Pick<QuestionnaireFlow, "getAnswer">,
141
+ state: Pick<OverlayRenderState, "stagedSelections">,
142
+ question: NormalizedStructuredQuestion,
143
+ ): number[] {
144
+ const answer = flow.getAnswer(question.id);
145
+ if (answer?.source === "other" || answer?.source === "discuss") return [];
146
+ const staged = state.stagedSelections.get(question.id);
147
+ if (staged) return [...staged];
148
+ if (!answer) {
149
+ if (question.defaultIndexes.length > 0) return [...question.defaultIndexes];
150
+ return [];
151
+ }
152
+ if (answer.source === "choice") return answer.selections.map((s) => s.optionIndex);
153
+ return [];
154
+ }
155
+
118
156
  export function selectedRowIndex(
119
157
  flow: Pick<QuestionnaireFlow, "getAnswer">,
120
158
  question: NormalizedQuestion | undefined,
@@ -123,15 +161,14 @@ export function selectedRowIndex(
123
161
  const rows = interactiveRows(question);
124
162
  const answer = flow.getAnswer(question.id);
125
163
  if (!answer) {
164
+ const defaultIdx = isStructuredQuestion(question) ? question.defaultIndexes[0] : undefined;
165
+ if (defaultIdx !== undefined) return defaultIdx;
126
166
  const recommended = primaryRecommendationIndex(question);
127
167
  return recommended ?? 0;
128
168
  }
129
169
  switch (answer.source) {
130
- case "option":
131
- case "yesno":
132
- return answer.optionIndex;
133
- case "options":
134
- return answer.optionIndexes[0] ?? 0;
170
+ case "choice":
171
+ return answer.selections[0]?.optionIndex ?? 0;
135
172
  case "other":
136
173
  return rows.findIndex((row) => row.kind === "other");
137
174
  case "discuss":
@@ -1,14 +1,19 @@
1
- // Rich questionnaire UI built on `ctx.ui.custom()`. Supports explicit choice,
2
- // multichoice, notes, other, discuss, preview, and review flows. Returns a
1
+ // Rich questionnaire UI built on `ctx.ui.custom()`. Supports explicit choice
2
+ // (single and multi-select), notes, other, discuss, preview, and review flows. Returns a
3
3
  // QuestionnaireOutcome whose terminal state is owned by the shared flow.
4
4
 
5
- import type { Theme } from "@mariozechner/pi-coding-agent";
6
- import { type Component, Editor, type EditorTheme, type TUI } from "@mariozechner/pi-tui";
7
- import { QuestionnaireFlow } from "./flow.ts";
8
- import type { NormalizedQuestion, QuestionnaireOutcome } from "./types.ts";
5
+ import type { Theme } from "@earendil-works/pi-coding-agent";
6
+ import { type Component, Editor, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
7
+ import { QuestionnaireFlow } from "../flow.ts";
8
+ import { renderOverlay } from "../render/ui-rich-render.ts";
9
+ import type { NormalizedQuestionnaire, QuestionnaireOutcome } from "../types.ts";
9
10
  import { handleOverlayInput, onEditorSubmit } from "./ui-rich-handlers.ts";
10
- import { renderOverlay } from "./ui-rich-render.ts";
11
- import { initialSubMode, type OverlayState, selectedRowIndex } from "./ui-rich-state.ts";
11
+ import {
12
+ initialSubMode,
13
+ type OverlayState,
14
+ selectedRowIndex,
15
+ textDefaultOrAnswer,
16
+ } from "./ui-rich-state.ts";
12
17
 
13
18
  export interface RichCustomOptions {
14
19
  overlay?: boolean;
@@ -33,10 +38,10 @@ export interface RunRichOptions {
33
38
  }
34
39
 
35
40
  export async function runRichQuestionnaire(
36
- questions: NormalizedQuestion[],
41
+ questionnaire: NormalizedQuestionnaire,
37
42
  opts: RunRichOptions,
38
43
  ): Promise<QuestionnaireOutcome | "unsupported"> {
39
- const flow = new QuestionnaireFlow(questions);
44
+ const flow = new QuestionnaireFlow(questionnaire.questions, questionnaire.allowSkip);
40
45
  // Short-circuit before opening the overlay if we were already aborted.
41
46
  // Otherwise signal.addEventListener("abort", …) would never fire and the
42
47
  // overlay would stay open until the user dismissed it manually.
@@ -44,6 +49,10 @@ export async function runRichQuestionnaire(
44
49
  flow.abort();
45
50
  return flow.outcome();
46
51
  }
52
+ // pi-tui's declarative keybinding system is not used here — the overlay
53
+ // implements its own dynamic input handling because footer hints change
54
+ // per mode and question type (e.g. Space toggle in multi-select vs Enter
55
+ // confirm in choice).
47
56
  const promise = opts.ui.custom<QuestionnaireOutcome>((tui, theme, _kb, done) =>
48
57
  buildOverlay({ tui, theme, flow, signal: opts.signal, done }),
49
58
  );
@@ -70,8 +79,13 @@ function buildOverlay(args: BuildOverlayArgs): Component {
70
79
  noteTarget: undefined,
71
80
  cachedLines: undefined,
72
81
  cachedWidth: undefined,
82
+ maxHeight: 0,
73
83
  };
74
84
  const editor = new Editor(tui, makeEditorTheme(theme));
85
+ const initialQuestion = flow.currentQuestion;
86
+ if (initialQuestion?.type === "text") {
87
+ editor.setText(textDefaultOrAnswer(flow, initialQuestion));
88
+ }
75
89
  const refresh = () => {
76
90
  state.cachedLines = undefined;
77
91
  tui.requestRender();
@@ -94,8 +108,19 @@ function buildOverlay(args: BuildOverlayArgs): Component {
94
108
  if (state.cachedWidth !== width) {
95
109
  state.cachedLines = undefined;
96
110
  state.cachedWidth = width;
111
+ state.maxHeight = 0;
112
+ }
113
+ if (!state.cachedLines) {
114
+ state.cachedLines = renderOverlay({ width, theme, flow, state, editor });
115
+ // Stabilize height — prevent shrinkage that triggers pi-tui's
116
+ // viewport tracking bug with differential rendering.
117
+ if (state.cachedLines.length > state.maxHeight) {
118
+ state.maxHeight = state.cachedLines.length;
119
+ }
120
+ while (state.cachedLines.length < state.maxHeight) {
121
+ state.cachedLines.push("");
122
+ }
97
123
  }
98
- if (!state.cachedLines) state.cachedLines = renderOverlay(width, theme, flow, state, editor);
99
124
  return state.cachedLines;
100
125
  },
101
126
  invalidate: () => {