@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.
- package/README.md +115 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
- package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/package.json +14 -7
- package/src/ask-user.ts +191 -0
- package/{flow.ts → src/flow.ts} +45 -33
- package/src/format.ts +66 -0
- package/src/index.ts +1 -0
- package/src/normalize.ts +229 -0
- package/{ui-rich-render-editor.ts → src/render/ui-rich-render-editor.ts} +18 -16
- package/src/render/ui-rich-render-env.ts +15 -0
- package/src/render/ui-rich-render-footer.ts +55 -0
- package/src/render/ui-rich-render-markdown.ts +33 -0
- package/{ui-rich-render-notes.ts → src/render/ui-rich-render-notes.ts} +20 -27
- package/src/render/ui-rich-render-types.ts +17 -0
- package/src/render/ui-rich-render.ts +323 -0
- package/{render.ts → src/render.ts} +10 -5
- package/{result.ts → src/result.ts} +27 -6
- package/{schema.ts → src/schema.ts} +46 -44
- package/{types.ts → src/types.ts} +20 -53
- package/{ui-rich-handlers.ts → src/ui/ui-rich-handlers.ts} +100 -44
- package/{ui-rich-inline.ts → src/ui/ui-rich-inline.ts} +23 -10
- package/{ui-rich-state.ts → src/ui/ui-rich-state.ts} +52 -15
- package/{ui-rich.ts → src/ui/ui-rich.ts} +36 -11
- package/ask-user.ts +0 -131
- package/format.ts +0 -95
- package/normalize.ts +0 -218
- package/ui-fallback.ts +0 -274
- 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" | "
|
|
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
|
|
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
|
|
58
|
+
export interface ChoiceAnswer {
|
|
77
59
|
questionId: string;
|
|
78
|
-
source: "
|
|
79
|
-
|
|
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
|
|
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:
|
|
134
|
-
maxPromptLength:
|
|
100
|
+
maxHeaderLength: 60,
|
|
101
|
+
maxPromptLength: 4000,
|
|
135
102
|
minChoiceOptions: 2,
|
|
136
|
-
maxChoiceOptions:
|
|
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
|
|
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 "@
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
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.
|
|
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
|
|
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.
|
|
148
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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.
|
|
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: "
|
|
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 "@
|
|
2
|
-
import type { Editor } from "@
|
|
3
|
-
import { truncateToWidth, visibleWidth } from "@
|
|
4
|
-
import type { QuestionnaireFlow } from "
|
|
5
|
-
import { DISCUSS_LABEL, OTHER_LABEL } from "
|
|
6
|
-
import type {
|
|
7
|
-
import type {
|
|
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
|
-
|
|
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
|
-
|
|
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 "@
|
|
4
|
-
import type { QuestionnaireFlow } from "
|
|
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 "
|
|
10
|
-
import { isStructuredQuestion, primaryRecommendationIndex } from "
|
|
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.
|
|
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
|
-
|
|
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 !== "
|
|
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 "
|
|
131
|
-
|
|
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
|
-
//
|
|
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 "@
|
|
6
|
-
import { type Component, Editor, type EditorTheme, type TUI } from "@
|
|
7
|
-
import { QuestionnaireFlow } from "
|
|
8
|
-
import
|
|
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 {
|
|
11
|
-
|
|
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
|
-
|
|
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: () => {
|