@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
package/src/format.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./ask-user.ts";
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Validates raw `ask_user` parameters and lowers them into the shared internal
|
|
2
|
+
// questionnaire model. Both UI paths and result formatting consume only this
|
|
3
|
+
// normalized model, so the overlay and dialog flows cannot drift apart.
|
|
4
|
+
|
|
5
|
+
import type { AskUserParams, ExternalQuestion } from "./schema.ts";
|
|
6
|
+
import {
|
|
7
|
+
type NormalizedOption,
|
|
8
|
+
type NormalizedQuestion,
|
|
9
|
+
type NormalizedQuestionnaire,
|
|
10
|
+
QUESTION_LIMITS,
|
|
11
|
+
} from "./types.ts";
|
|
12
|
+
|
|
13
|
+
export class AskUserValidationError extends Error {
|
|
14
|
+
constructor(message: string) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "AskUserValidationError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeQuestionnaire(params: AskUserParams): NormalizedQuestionnaire {
|
|
21
|
+
validateQuestionnaireShape(params);
|
|
22
|
+
return {
|
|
23
|
+
questions: params.questions.map((q) => normalizeQuestion(q)),
|
|
24
|
+
allowSkip: params.allowSkip ?? false,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validateQuestionnaireShape(params: AskUserParams): void {
|
|
29
|
+
const count = params.questions.length;
|
|
30
|
+
if (count < QUESTION_LIMITS.minQuestions || count > QUESTION_LIMITS.maxQuestions) {
|
|
31
|
+
throw new AskUserValidationError(
|
|
32
|
+
`ask_user supports ${QUESTION_LIMITS.minQuestions}-${QUESTION_LIMITS.maxQuestions} questions only (got ${count}).`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const seen = new Set<string>();
|
|
36
|
+
for (const q of params.questions) {
|
|
37
|
+
const questionId = q.id.trim();
|
|
38
|
+
if (questionId.length === 0) continue;
|
|
39
|
+
if (seen.has(questionId)) {
|
|
40
|
+
throw new AskUserValidationError(
|
|
41
|
+
`Duplicate question id "${questionId}" — question ids must be unique within a questionnaire.`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
seen.add(questionId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeQuestion(q: ExternalQuestion): NormalizedQuestion {
|
|
49
|
+
validateCommonFields(q);
|
|
50
|
+
switch (q.type) {
|
|
51
|
+
case "choice":
|
|
52
|
+
return normalizeChoice(q);
|
|
53
|
+
case "text":
|
|
54
|
+
return normalizeText(q);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validateCommonFields(q: ExternalQuestion): void {
|
|
59
|
+
if (q.id.trim().length === 0) {
|
|
60
|
+
throw new AskUserValidationError("Question id must be a non-empty string.");
|
|
61
|
+
}
|
|
62
|
+
if (q.header.trim().length === 0) {
|
|
63
|
+
throw new AskUserValidationError(`Question "${q.id}" must include a non-empty header.`);
|
|
64
|
+
}
|
|
65
|
+
if (q.header.length > QUESTION_LIMITS.maxHeaderLength) {
|
|
66
|
+
throw new AskUserValidationError(
|
|
67
|
+
`Question "${q.id}" header exceeds ${QUESTION_LIMITS.maxHeaderLength} characters.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (q.prompt.trim().length === 0) {
|
|
71
|
+
throw new AskUserValidationError(`Question "${q.id}" must include a non-empty prompt.`);
|
|
72
|
+
}
|
|
73
|
+
if (q.prompt.length > QUESTION_LIMITS.maxPromptLength) {
|
|
74
|
+
throw new AskUserValidationError(
|
|
75
|
+
`Question "${q.id}" prompt exceeds ${QUESTION_LIMITS.maxPromptLength} characters.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeChoice(q: {
|
|
81
|
+
type: "choice";
|
|
82
|
+
id: string;
|
|
83
|
+
header: string;
|
|
84
|
+
prompt: string;
|
|
85
|
+
required?: boolean;
|
|
86
|
+
multi?: boolean;
|
|
87
|
+
options: { value: string; label: string; description?: string; preview?: string }[];
|
|
88
|
+
allowOther?: boolean;
|
|
89
|
+
allowDiscuss?: boolean;
|
|
90
|
+
recommendation?: string | string[];
|
|
91
|
+
default?: string | string[];
|
|
92
|
+
}): NormalizedQuestion {
|
|
93
|
+
const id = q.id.trim();
|
|
94
|
+
const options = normalizeStructuredOptions(id, q.options);
|
|
95
|
+
const multi = q.multi ?? false;
|
|
96
|
+
const recommendation = q.recommendation;
|
|
97
|
+
const defaultValue = q.default;
|
|
98
|
+
|
|
99
|
+
validateRecDefaultShape(id, recommendation, multi, "recommendation");
|
|
100
|
+
validateRecDefaultShape(id, defaultValue, multi, "default");
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
id,
|
|
104
|
+
header: q.header,
|
|
105
|
+
type: "choice",
|
|
106
|
+
prompt: q.prompt,
|
|
107
|
+
required: q.required ?? true,
|
|
108
|
+
multi,
|
|
109
|
+
options,
|
|
110
|
+
allowOther: q.allowOther ?? false,
|
|
111
|
+
allowDiscuss: q.allowDiscuss ?? false,
|
|
112
|
+
recommendedIndexes: resolveRecDefault(id, options, recommendation, multi, "recommendation"),
|
|
113
|
+
defaultIndexes: resolveRecDefault(id, options, defaultValue, multi, "default"),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function validateRecDefaultShape(
|
|
118
|
+
questionId: string,
|
|
119
|
+
value: string | string[] | undefined,
|
|
120
|
+
multi: boolean,
|
|
121
|
+
kind: string,
|
|
122
|
+
): void {
|
|
123
|
+
if (value === undefined) return;
|
|
124
|
+
if (!multi && Array.isArray(value)) {
|
|
125
|
+
throw new AskUserValidationError(
|
|
126
|
+
`single-select question "${questionId}" ${kind} must be a string, not an array.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (multi && !Array.isArray(value)) {
|
|
130
|
+
throw new AskUserValidationError(
|
|
131
|
+
`multi-select question "${questionId}" ${kind} must be an array, not a string.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeText(q: {
|
|
137
|
+
id: string;
|
|
138
|
+
header: string;
|
|
139
|
+
prompt: string;
|
|
140
|
+
required?: boolean;
|
|
141
|
+
default?: string;
|
|
142
|
+
}): NormalizedQuestion {
|
|
143
|
+
return {
|
|
144
|
+
id: q.id.trim(),
|
|
145
|
+
header: q.header,
|
|
146
|
+
type: "text",
|
|
147
|
+
prompt: q.prompt,
|
|
148
|
+
required: q.required ?? true,
|
|
149
|
+
options: [],
|
|
150
|
+
...(q.default !== undefined ? { default: q.default.trim() } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeStructuredOptions(
|
|
155
|
+
questionId: string,
|
|
156
|
+
options: { value: string; label: string; description?: string; preview?: string }[],
|
|
157
|
+
): NormalizedOption[] {
|
|
158
|
+
const optionCount = options.length;
|
|
159
|
+
if (
|
|
160
|
+
optionCount < QUESTION_LIMITS.minChoiceOptions ||
|
|
161
|
+
optionCount > QUESTION_LIMITS.maxChoiceOptions
|
|
162
|
+
) {
|
|
163
|
+
throw new AskUserValidationError(
|
|
164
|
+
`choice question "${questionId}" must have ${QUESTION_LIMITS.minChoiceOptions}-${QUESTION_LIMITS.maxChoiceOptions} options (got ${optionCount}).`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const seenValues = new Set<string>();
|
|
168
|
+
return options.map((opt) => {
|
|
169
|
+
const value = opt.value.trim();
|
|
170
|
+
if (value.length === 0 || opt.label.trim().length === 0) {
|
|
171
|
+
throw new AskUserValidationError(
|
|
172
|
+
`choice question "${questionId}" has an option with empty value or label.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (seenValues.has(value)) {
|
|
176
|
+
throw new AskUserValidationError(
|
|
177
|
+
`choice question "${questionId}" has duplicate option value "${value}".`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
seenValues.add(value);
|
|
181
|
+
return {
|
|
182
|
+
value,
|
|
183
|
+
label: opt.label,
|
|
184
|
+
description: opt.description,
|
|
185
|
+
preview: opt.preview,
|
|
186
|
+
};
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// biome-ignore lint/complexity/useMaxParams: five distinct positional params are cleaner than a non-reusable options object for this internal helper
|
|
191
|
+
function resolveRecDefault(
|
|
192
|
+
questionId: string,
|
|
193
|
+
options: NormalizedOption[],
|
|
194
|
+
value: string | string[] | undefined,
|
|
195
|
+
multi: boolean,
|
|
196
|
+
kind: "recommendation" | "default",
|
|
197
|
+
): number[] {
|
|
198
|
+
if (value === undefined) return [];
|
|
199
|
+
const verb = kind === "recommendation" ? "recommends" : "defaults to";
|
|
200
|
+
if (!multi) {
|
|
201
|
+
const trimmed = (value as string).trim();
|
|
202
|
+
const idx = options.findIndex((opt) => opt.value === trimmed);
|
|
203
|
+
if (idx < 0) {
|
|
204
|
+
throw new AskUserValidationError(
|
|
205
|
+
`choice question "${questionId}" ${verb} "${trimmed}", which is not one of its option values.`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return [idx];
|
|
209
|
+
}
|
|
210
|
+
const values = value as string[];
|
|
211
|
+
if (values.length === 0) return [];
|
|
212
|
+
const seen = new Set<string>();
|
|
213
|
+
return values.map((v) => {
|
|
214
|
+
const trimmed = v.trim();
|
|
215
|
+
if (seen.has(trimmed)) {
|
|
216
|
+
throw new AskUserValidationError(
|
|
217
|
+
`choice question "${questionId}" has duplicate ${kind === "recommendation" ? "recommended" : "default"} value "${trimmed}".`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
seen.add(trimmed);
|
|
221
|
+
const idx = options.findIndex((opt) => opt.value === trimmed);
|
|
222
|
+
if (idx < 0) {
|
|
223
|
+
throw new AskUserValidationError(
|
|
224
|
+
`choice question "${questionId}" ${verb} "${trimmed}", which is not one of its option values.`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return idx;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Editor pane rendering helpers for the rich overlay.
|
|
2
2
|
|
|
3
|
-
import type { Theme } from "@
|
|
4
|
-
import type
|
|
5
|
-
import {
|
|
6
|
-
import type { OverlayRenderState } from "./ui-rich-render.ts";
|
|
3
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { type Editor, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import type { RenderEnv } from "./ui-rich-render-env.ts";
|
|
6
|
+
import type { OverlayRenderState } from "./ui-rich-render-types.ts";
|
|
7
7
|
|
|
8
8
|
export function editorCaption(state: OverlayRenderState): string {
|
|
9
9
|
if (state.subMode === "other-input") return "Other answer";
|
|
@@ -12,8 +12,14 @@ export function editorCaption(state: OverlayRenderState): string {
|
|
|
12
12
|
return "Answer";
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** Whether the editor should render in a separate pane (split-view right side)
|
|
16
|
+
* rather than inline within the option rows. */
|
|
15
17
|
export function usesSeparateEditorPane(state: OverlayRenderState): boolean {
|
|
16
|
-
return
|
|
18
|
+
return (
|
|
19
|
+
state.subMode === "note-input" ||
|
|
20
|
+
state.subMode === "other-input" ||
|
|
21
|
+
state.subMode === "discuss-input"
|
|
22
|
+
);
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
export function renderEditorPane(
|
|
@@ -29,17 +35,13 @@ export function renderEditorPane(
|
|
|
29
35
|
return out;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
caption: string,
|
|
40
|
-
): void {
|
|
41
|
-
add(theme.fg("muted", ` ${caption}:`));
|
|
42
|
-
for (const line of editor.render(width - 2)) lines.push(` ${truncateToWidth(line, width - 1)}`);
|
|
38
|
+
export function renderEditorBlock(env: RenderEnv, caption: string): string[] {
|
|
39
|
+
const out: string[] = [];
|
|
40
|
+
out.push(env.theme.fg("muted", ` ${caption}:`));
|
|
41
|
+
for (const line of env.editor.render(env.width - 2)) {
|
|
42
|
+
out.push(` ${truncateToWidth(line, env.width - 1)}`);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
export function padRight(text: string, width: number): string {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Shared rendering context for the rich overlay questionnaire UI.
|
|
2
|
+
// Bundles the common parameters so individual helpers stay within param limits.
|
|
3
|
+
|
|
4
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Editor } from "@earendil-works/pi-tui";
|
|
6
|
+
import type { QuestionnaireFlow } from "../flow.ts";
|
|
7
|
+
import type { OverlayRenderState } from "./ui-rich-render-types.ts";
|
|
8
|
+
|
|
9
|
+
export interface RenderEnv {
|
|
10
|
+
width: number;
|
|
11
|
+
theme: Theme;
|
|
12
|
+
flow: QuestionnaireFlow;
|
|
13
|
+
state: OverlayRenderState;
|
|
14
|
+
editor: Editor;
|
|
15
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Footer help text generation for the rich overlay questionnaire UI.
|
|
2
|
+
// Extracted from ui-rich-render.ts to stay within Biome's per-file line limit.
|
|
3
|
+
|
|
4
|
+
import type { QuestionnaireFlow } from "../flow.ts";
|
|
5
|
+
import type { NormalizedChoiceQuestion } from "../types.ts";
|
|
6
|
+
import { isEditorMode, selectedIndexesForQuestion } from "../ui/ui-rich-state.ts";
|
|
7
|
+
import { currentNote, currentRowSupportsNotes } from "./ui-rich-render-notes.ts";
|
|
8
|
+
import type { OverlayRenderState } from "./ui-rich-render-types.ts";
|
|
9
|
+
|
|
10
|
+
export function footerHelp(flow: QuestionnaireFlow, state: OverlayRenderState): string {
|
|
11
|
+
if (state.subMode === "text-input") {
|
|
12
|
+
const question = flow.currentQuestion;
|
|
13
|
+
const parts = ["Enter to submit"];
|
|
14
|
+
if (question && !question.required) parts.push("Esc skip question");
|
|
15
|
+
else parts.push("Esc to cancel");
|
|
16
|
+
if (flow.showSkip) parts.push("Ctrl-S skip");
|
|
17
|
+
return parts.join(" • ");
|
|
18
|
+
}
|
|
19
|
+
if (isEditorMode(state.subMode)) return "Enter to submit • Esc to go back";
|
|
20
|
+
if (flow.currentMode === "reviewing") {
|
|
21
|
+
const base = "Enter to submit • ←/Shift-Tab to revise • Esc to cancel";
|
|
22
|
+
return flow.showSkip ? `${base} • s to skip` : base;
|
|
23
|
+
}
|
|
24
|
+
const question = flow.currentQuestion;
|
|
25
|
+
if (!question || question.type === "text") {
|
|
26
|
+
const base = "Esc cancel";
|
|
27
|
+
return flow.showSkip ? `${base} • s to skip` : base;
|
|
28
|
+
}
|
|
29
|
+
return structuredFooterHelp(flow, state, question);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function structuredFooterHelp(
|
|
33
|
+
flow: QuestionnaireFlow,
|
|
34
|
+
state: OverlayRenderState,
|
|
35
|
+
question: NormalizedChoiceQuestion,
|
|
36
|
+
): string {
|
|
37
|
+
const canGoBack = flow.currentIndex > 0;
|
|
38
|
+
const canAdvance =
|
|
39
|
+
flow.allRequiredAnswered() || (!question.required && !flow.hasAnswer(question.id));
|
|
40
|
+
const parts = ["↑↓ navigate"];
|
|
41
|
+
if (question.multi) {
|
|
42
|
+
parts.push("Space toggle");
|
|
43
|
+
if (selectedIndexesForQuestion(flow, state, question).length > 0) parts.push("Enter submit");
|
|
44
|
+
} else {
|
|
45
|
+
parts.push("Enter confirm/select");
|
|
46
|
+
}
|
|
47
|
+
if (currentRowSupportsNotes(question, state)) {
|
|
48
|
+
parts.push(currentNote(flow, state, question) ? "n edit note" : "n add note");
|
|
49
|
+
}
|
|
50
|
+
if (canGoBack) parts.push("←/Shift-Tab back");
|
|
51
|
+
if (canAdvance) parts.push("→/Tab next");
|
|
52
|
+
if (flow.showSkip) parts.push("s skip");
|
|
53
|
+
parts.push("Esc cancel");
|
|
54
|
+
return parts.join(" • ");
|
|
55
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getMarkdownTheme, highlightCode } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Markdown } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
export interface RenderMarkdownOptions {
|
|
6
|
+
paddingX?: number;
|
|
7
|
+
defaultColor?: "text" | "muted" | "dim";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function renderMarkdown(
|
|
11
|
+
text: string,
|
|
12
|
+
width: number,
|
|
13
|
+
theme: Theme,
|
|
14
|
+
options: RenderMarkdownOptions = {},
|
|
15
|
+
): string[] {
|
|
16
|
+
const { paddingX = 1, defaultColor = "text" } = options;
|
|
17
|
+
const markdownTheme = getMarkdownTheme();
|
|
18
|
+
markdownTheme.highlightCode = (code: string, lang?: string) => {
|
|
19
|
+
try {
|
|
20
|
+
return highlightCode(code, lang);
|
|
21
|
+
} catch {
|
|
22
|
+
return code.split("\n").map((line) => markdownTheme.codeBlock(line));
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const markdown = new Markdown(text, paddingX, 0, markdownTheme, {
|
|
26
|
+
color: (text: string) => theme.fg(defaultColor, text),
|
|
27
|
+
});
|
|
28
|
+
return markdown.render(width);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderMarkdownPreview(preview: string, width: number, theme: Theme): string[] {
|
|
32
|
+
return renderMarkdown(preview, width, theme);
|
|
33
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Note-related rendering helpers for the rich overlay.
|
|
2
2
|
|
|
3
|
-
import type { Theme } from "@
|
|
4
|
-
import type { QuestionnaireFlow } from "
|
|
5
|
-
import type {
|
|
6
|
-
import type
|
|
7
|
-
import
|
|
3
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { QuestionnaireFlow } from "../flow.ts";
|
|
5
|
+
import type { NormalizedQuestion, NormalizedStructuredQuestion, Selection } from "../types.ts";
|
|
6
|
+
import { type InteractiveRow, interactiveRows } from "../ui/ui-rich-state.ts";
|
|
7
|
+
import type { OverlayRenderState } from "./ui-rich-render-types.ts";
|
|
8
8
|
|
|
9
9
|
export function currentNote(
|
|
10
10
|
flow: Pick<QuestionnaireFlow, "getAnswer">,
|
|
@@ -14,8 +14,7 @@ export function currentNote(
|
|
|
14
14
|
if (!question || question.type === "text") return undefined;
|
|
15
15
|
const row = interactiveRows(question)[state.selectedIndex];
|
|
16
16
|
if (!row || row.kind !== "option") return undefined;
|
|
17
|
-
if (question.
|
|
18
|
-
return noteForMultiOption(flow, state, question, row.optionIndex);
|
|
17
|
+
if (question.multi) return noteForMultiOption(flow, state, question, row.optionIndex);
|
|
19
18
|
return noteForSingle(flow, state, question);
|
|
20
19
|
}
|
|
21
20
|
|
|
@@ -36,15 +35,14 @@ export function visibleNoteMarker(args: {
|
|
|
36
35
|
active: boolean;
|
|
37
36
|
}): boolean {
|
|
38
37
|
const { flow, state, question, row, active } = args;
|
|
39
|
-
if (question.
|
|
40
|
-
return !!noteForMultiOption(flow, state, question, row.optionIndex);
|
|
38
|
+
if (question.multi) return !!noteForMultiOption(flow, state, question, row.optionIndex);
|
|
41
39
|
return active && !!noteForSingle(flow, state, question);
|
|
42
40
|
}
|
|
43
41
|
|
|
44
42
|
export function noteForSingle(
|
|
45
43
|
flow: Pick<QuestionnaireFlow, "getAnswer">,
|
|
46
44
|
state: Pick<OverlayRenderState, "stagedSingleNotes">,
|
|
47
|
-
question:
|
|
45
|
+
question: NormalizedStructuredQuestion,
|
|
48
46
|
): string | undefined {
|
|
49
47
|
return state.stagedSingleNotes.get(question.id) ?? storedSingleNote(flow.getAnswer(question.id));
|
|
50
48
|
}
|
|
@@ -52,36 +50,31 @@ export function noteForSingle(
|
|
|
52
50
|
function storedSingleNote(
|
|
53
51
|
answer: ReturnType<Pick<QuestionnaireFlow, "getAnswer">["getAnswer"]>,
|
|
54
52
|
): string | undefined {
|
|
55
|
-
if (!answer) return undefined;
|
|
56
|
-
|
|
57
|
-
return undefined;
|
|
53
|
+
if (!answer || answer.source !== "choice") return undefined;
|
|
54
|
+
return answer.selections[0]?.note;
|
|
58
55
|
}
|
|
59
56
|
|
|
60
57
|
export function noteForMultiOption(
|
|
61
58
|
flow: Pick<QuestionnaireFlow, "getAnswer">,
|
|
62
59
|
state: Pick<OverlayRenderState, "stagedMultiNotes">,
|
|
63
|
-
question:
|
|
60
|
+
question: NormalizedStructuredQuestion,
|
|
64
61
|
optionIndex: number,
|
|
65
62
|
): string | undefined {
|
|
63
|
+
const answer = flow.getAnswer(question.id);
|
|
64
|
+
if (answer?.source === "other" || answer?.source === "discuss") return undefined;
|
|
66
65
|
const staged = state.stagedMultiNotes.get(question.id)?.get(optionIndex);
|
|
67
66
|
if (staged !== undefined) return staged;
|
|
68
|
-
return storedMultiSelections(
|
|
69
|
-
|
|
70
|
-
)?.note;
|
|
67
|
+
return storedMultiSelections(answer).find((selection) => selection.optionIndex === optionIndex)
|
|
68
|
+
?.note;
|
|
71
69
|
}
|
|
72
70
|
|
|
73
71
|
function storedMultiSelections(
|
|
74
72
|
answer: ReturnType<Pick<QuestionnaireFlow, "getAnswer">["getAnswer"]>,
|
|
75
|
-
):
|
|
76
|
-
if (!answer || answer.source !== "
|
|
77
|
-
|
|
78
|
-
return answer.optionIndexes.map((optionIndex, index) => ({
|
|
79
|
-
optionIndex,
|
|
80
|
-
value: answer.values[index] ?? "",
|
|
81
|
-
}));
|
|
73
|
+
): Selection[] {
|
|
74
|
+
if (!answer || answer.source !== "choice") return [];
|
|
75
|
+
return answer.selections;
|
|
82
76
|
}
|
|
83
77
|
|
|
84
|
-
export function renderNoteStatus(
|
|
85
|
-
|
|
86
|
-
add(theme.fg("muted", ` Notes: ${note}`));
|
|
78
|
+
export function renderNoteStatus(theme: Theme, note: string): string[] {
|
|
79
|
+
return ["", theme.fg("muted", ` Notes: ${note}`)];
|
|
87
80
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Shared types used by the rich overlay rendering pipeline.
|
|
2
|
+
// Extracted to avoid import cycles between render modules.
|
|
3
|
+
|
|
4
|
+
export type SubMode = "select" | "other-input" | "text-input" | "discuss-input" | "note-input";
|
|
5
|
+
|
|
6
|
+
export interface OverlayRenderState {
|
|
7
|
+
/** Current cursor row index within the interactive rows list. */
|
|
8
|
+
selectedIndex: number;
|
|
9
|
+
/** Active input mode: select, text-input, other-input, discuss-input, note-input. */
|
|
10
|
+
subMode: SubMode;
|
|
11
|
+
/** Uncommitted multi-select checkbox state (questionId → sorted optionIndexes). */
|
|
12
|
+
stagedSelections: Map<string, number[]>;
|
|
13
|
+
/** Draft note text for single-select questions (questionId → note). */
|
|
14
|
+
stagedSingleNotes: Map<string, string>;
|
|
15
|
+
/** Draft note text per-option for multi-select questions (questionId → Map<optionIndex, note>). */
|
|
16
|
+
stagedMultiNotes: Map<string, Map<number, string>>;
|
|
17
|
+
}
|