@mrclrchtr/supi-ask-user 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +125 -67
  2. package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
  3. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +13 -13
  5. package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
  6. package/node_modules/@mrclrchtr/supi-core/src/{context-provider-registry.ts → context/context-provider-registry.ts} +1 -1
  7. package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/index.ts +13 -13
  9. package/node_modules/@mrclrchtr/supi-core/src/{settings-registry.ts → settings/settings-registry.ts} +1 -1
  10. package/package.json +2 -2
  11. package/src/api.ts +19 -0
  12. package/src/ask-user.ts +65 -131
  13. package/src/index.ts +23 -1
  14. package/src/normalize.ts +153 -142
  15. package/src/render/result.ts +98 -0
  16. package/src/render/transcript.ts +65 -0
  17. package/src/render/tree-summary.ts +10 -0
  18. package/src/schema.ts +41 -38
  19. package/src/session/controller.ts +163 -0
  20. package/src/session/lock.ts +19 -0
  21. package/src/tool/guidance.ts +15 -0
  22. package/src/types.ts +50 -56
  23. package/src/ui/choose-renderer.ts +11 -0
  24. package/src/ui/overlay-actions.ts +42 -0
  25. package/src/ui/overlay-render.ts +196 -0
  26. package/src/ui/overlay-view.ts +216 -0
  27. package/src/ui/overlay.ts +388 -0
  28. package/src/ui/types.ts +35 -0
  29. package/src/flow.ts +0 -224
  30. package/src/format.ts +0 -66
  31. package/src/render/ui-rich-render-editor.ts +0 -51
  32. package/src/render/ui-rich-render-env.ts +0 -15
  33. package/src/render/ui-rich-render-footer.ts +0 -55
  34. package/src/render/ui-rich-render-markdown.ts +0 -33
  35. package/src/render/ui-rich-render-notes.ts +0 -80
  36. package/src/render/ui-rich-render-types.ts +0 -17
  37. package/src/render/ui-rich-render.ts +0 -323
  38. package/src/render.ts +0 -95
  39. package/src/result.ts +0 -90
  40. package/src/ui/ui-rich-handlers.ts +0 -369
  41. package/src/ui/ui-rich-inline.ts +0 -77
  42. package/src/ui/ui-rich-state.ts +0 -179
  43. package/src/ui/ui-rich.ts +0 -144
  44. /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
  45. /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
  46. /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
  47. /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
  48. /package/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
@@ -1,51 +0,0 @@
1
- // Editor pane rendering helpers for the rich overlay.
2
-
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
-
8
- export function editorCaption(state: OverlayRenderState): string {
9
- if (state.subMode === "other-input") return "Other answer";
10
- if (state.subMode === "discuss-input") return "Discuss";
11
- if (state.subMode === "note-input") return "Note";
12
- return "Answer";
13
- }
14
-
15
- /** Whether the editor should render in a separate pane (split-view right side)
16
- * rather than inline within the option rows. */
17
- export function usesSeparateEditorPane(state: OverlayRenderState): boolean {
18
- return (
19
- state.subMode === "note-input" ||
20
- state.subMode === "other-input" ||
21
- state.subMode === "discuss-input"
22
- );
23
- }
24
-
25
- export function renderEditorPane(
26
- width: number,
27
- theme: Theme,
28
- editor: Editor,
29
- caption: string,
30
- ): string[] {
31
- const out: string[] = [];
32
- out.push(truncateToWidth(theme.fg("accent", ` ${caption}`), width));
33
- out.push("");
34
- for (const line of editor.render(width - 2)) out.push(truncateToWidth(` ${line}`, width));
35
- return out;
36
- }
37
-
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;
45
- }
46
-
47
- export function padRight(text: string, width: number): string {
48
- const visible = truncateToWidth(text, width);
49
- const padding = Math.max(0, width - visibleWidth(visible));
50
- return `${visible}${" ".repeat(padding)}`;
51
- }
@@ -1,15 +0,0 @@
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
- }
@@ -1,55 +0,0 @@
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
- }
@@ -1,33 +0,0 @@
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,80 +0,0 @@
1
- // Note-related rendering helpers for the rich overlay.
2
-
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
-
9
- export function currentNote(
10
- flow: Pick<QuestionnaireFlow, "getAnswer">,
11
- state: OverlayRenderState,
12
- question: NormalizedQuestion | undefined,
13
- ): string | undefined {
14
- if (!question || question.type === "text") return undefined;
15
- const row = interactiveRows(question)[state.selectedIndex];
16
- if (!row || row.kind !== "option") return undefined;
17
- if (question.multi) return noteForMultiOption(flow, state, question, row.optionIndex);
18
- return noteForSingle(flow, state, question);
19
- }
20
-
21
- export function currentRowSupportsNotes(
22
- question: NormalizedQuestion | undefined,
23
- state: Pick<OverlayRenderState, "selectedIndex">,
24
- ): boolean {
25
- if (!question || question.type === "text") return false;
26
- const row = interactiveRows(question)[state.selectedIndex];
27
- return row?.kind === "option";
28
- }
29
-
30
- export function visibleNoteMarker(args: {
31
- flow: Pick<QuestionnaireFlow, "getAnswer">;
32
- state: OverlayRenderState;
33
- question: NormalizedStructuredQuestion;
34
- row: Extract<InteractiveRow, { kind: "option" }>;
35
- active: boolean;
36
- }): boolean {
37
- const { flow, state, question, row, active } = args;
38
- if (question.multi) return !!noteForMultiOption(flow, state, question, row.optionIndex);
39
- return active && !!noteForSingle(flow, state, question);
40
- }
41
-
42
- export function noteForSingle(
43
- flow: Pick<QuestionnaireFlow, "getAnswer">,
44
- state: Pick<OverlayRenderState, "stagedSingleNotes">,
45
- question: NormalizedStructuredQuestion,
46
- ): string | undefined {
47
- return state.stagedSingleNotes.get(question.id) ?? storedSingleNote(flow.getAnswer(question.id));
48
- }
49
-
50
- function storedSingleNote(
51
- answer: ReturnType<Pick<QuestionnaireFlow, "getAnswer">["getAnswer"]>,
52
- ): string | undefined {
53
- if (!answer || answer.source !== "choice") return undefined;
54
- return answer.selections[0]?.note;
55
- }
56
-
57
- export function noteForMultiOption(
58
- flow: Pick<QuestionnaireFlow, "getAnswer">,
59
- state: Pick<OverlayRenderState, "stagedMultiNotes">,
60
- question: NormalizedStructuredQuestion,
61
- optionIndex: number,
62
- ): string | undefined {
63
- const answer = flow.getAnswer(question.id);
64
- if (answer?.source === "other" || answer?.source === "discuss") return undefined;
65
- const staged = state.stagedMultiNotes.get(question.id)?.get(optionIndex);
66
- if (staged !== undefined) return staged;
67
- return storedMultiSelections(answer).find((selection) => selection.optionIndex === optionIndex)
68
- ?.note;
69
- }
70
-
71
- function storedMultiSelections(
72
- answer: ReturnType<Pick<QuestionnaireFlow, "getAnswer">["getAnswer"]>,
73
- ): Selection[] {
74
- if (!answer || answer.source !== "choice") return [];
75
- return answer.selections;
76
- }
77
-
78
- export function renderNoteStatus(theme: Theme, note: string): string[] {
79
- return ["", theme.fg("muted", ` Notes: ${note}`)];
80
- }
@@ -1,17 +0,0 @@
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
- }
@@ -1,323 +0,0 @@
1
- // Pure rendering helpers for the rich overlay. Kept separate from
2
- // ui-rich.ts to stay within Biome's per-file line limit and so the input
3
- // dispatch logic can be read without scrolling past a wall of theme strings.
4
-
5
- import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
6
- import { decorateOption, formatReviewLines, NOTE_MARKER } from "../format.ts";
7
- import type { NormalizedStructuredQuestion } from "../types.ts";
8
- import { inlineStructuredRowLines, structuredRowLabel } from "../ui/ui-rich-inline.ts";
9
- import {
10
- hasPreview,
11
- type InteractiveRow,
12
- interactiveRows,
13
- selectedIndexesForQuestion,
14
- } from "../ui/ui-rich-state.ts";
15
- import {
16
- editorCaption,
17
- padRight,
18
- renderEditorBlock,
19
- renderEditorPane,
20
- usesSeparateEditorPane,
21
- } from "./ui-rich-render-editor.ts";
22
- import type { RenderEnv } from "./ui-rich-render-env.ts";
23
- import { footerHelp } from "./ui-rich-render-footer.ts";
24
- import { renderMarkdown, renderMarkdownPreview } from "./ui-rich-render-markdown.ts";
25
- import { currentNote, renderNoteStatus, visibleNoteMarker } from "./ui-rich-render-notes.ts";
26
-
27
- export function renderOverlay(env: RenderEnv): string[] {
28
- const lines: string[] = [];
29
- lines.push(env.theme.fg("accent", "─".repeat(env.width)));
30
- if (env.flow.isMultiQuestion) renderTabBar(lines, env);
31
- if (env.flow.currentMode === "reviewing") {
32
- lines.push(...renderReview(env));
33
- } else {
34
- renderQuestion(lines, env);
35
- }
36
- lines.push(
37
- truncateToWidth(env.theme.fg("dim", ` ${footerHelp(env.flow, env.state)}`), env.width),
38
- );
39
- lines.push(env.theme.fg("accent", "─".repeat(env.width)));
40
- return lines;
41
- }
42
-
43
- function tabSegment(
44
- env: RenderEnv,
45
- text: string,
46
- active: boolean,
47
- color: "success" | "muted" | "dim" | "text",
48
- ): string {
49
- return active
50
- ? env.theme.bg("selectedBg", env.theme.fg("text", text))
51
- : env.theme.fg(color, text);
52
- }
53
-
54
- function renderTabBar(lines: string[], env: RenderEnv): void {
55
- const segments: string[] = [env.theme.fg("dim", "← ")];
56
- for (const [index, question] of env.flow.questions.entries()) {
57
- const answered = env.flow.hasAnswer(question.id);
58
- const active = env.flow.currentMode === "answering" && env.flow.currentIndex === index;
59
- const marker = answered ? "■" : question.required ? "□" : "○";
60
- const color = answered ? "success" : question.required ? "muted" : "dim";
61
- segments.push(tabSegment(env, ` ${marker} ${question.header} `, active, color));
62
- segments.push(" ");
63
- }
64
- const reviewActive = env.flow.currentMode === "reviewing";
65
- segments.push(tabSegment(env, " ✓ Review ", reviewActive, reviewActive ? "text" : "dim"));
66
- segments.push(env.theme.fg("dim", " →"));
67
- lines.push(truncateToWidth(` ${segments.join("")}`, env.width));
68
- lines.push("");
69
- }
70
-
71
- function renderQuestion(lines: string[], env: RenderEnv): void {
72
- const question = env.flow.currentQuestion;
73
- if (!question) return;
74
- lines.push(...renderMarkdown(question.prompt, env.width - 1, env.theme, { paddingX: 0 }));
75
- lines.push("");
76
- if (question.type === "text") {
77
- renderTextQuestion(lines, env);
78
- return;
79
- }
80
- renderStructuredQuestion(lines, env, question);
81
- }
82
-
83
- function renderSplitView(
84
- lines: string[],
85
- env: RenderEnv,
86
- question: NormalizedStructuredQuestion,
87
- rows: InteractiveRow[],
88
- ): void {
89
- const leftWidth = Math.max(36, Math.floor(env.width * 0.55));
90
- const rightWidth = Math.max(24, env.width - leftWidth - 3);
91
- const leftEnv: RenderEnv = { ...env, width: leftWidth };
92
- const leftLines = renderPaneRows(leftEnv, question, rows);
93
- const rightLines = usesSeparateEditorPane(env.state)
94
- ? renderEditorPane(rightWidth, env.theme, env.editor, editorCaption(env.state))
95
- : renderPreviewPane(
96
- rightWidth,
97
- env.theme,
98
- previewForSelection(question, rows[env.state.selectedIndex]),
99
- );
100
- const total = Math.max(leftLines.length, rightLines.length);
101
- for (let index = 0; index < total; index += 1) {
102
- const left = padRight(leftLines[index] ?? "", leftWidth);
103
- const right = padRight(rightLines[index] ?? "", rightWidth);
104
- lines.push(`${left} ${env.theme.fg("accent", "│")} ${right}`);
105
- }
106
- }
107
-
108
- function renderTextQuestion(lines: string[], env: RenderEnv): void {
109
- lines.push(...renderEditorBlock(env, "Answer"));
110
- }
111
-
112
- function renderStructuredQuestion(
113
- lines: string[],
114
- env: RenderEnv,
115
- question: NormalizedStructuredQuestion,
116
- ): void {
117
- const rows = interactiveRows(question);
118
- // Split view at >=100 cols: left pane 42% (min 34 cols) for option rows,
119
- // right pane (min 24 cols) for preview or editor. Below this threshold,
120
- // previews render as an inline block below the options.
121
- if (hasPreview(question) && env.width >= 100) {
122
- renderSplitView(lines, env, question, rows);
123
- } else {
124
- renderStandardStructuredQuestion(lines, env, question, rows);
125
- }
126
- const note = currentNote(env.flow, env.state, question);
127
- if (note) lines.push(...renderNoteStatus(env.theme, note));
128
- }
129
-
130
- function renderStandardStructuredQuestion(
131
- lines: string[],
132
- env: RenderEnv,
133
- question: NormalizedStructuredQuestion,
134
- rows: InteractiveRow[],
135
- ): void {
136
- lines.push(...renderRows(env, question, rows));
137
- if (usesSeparateEditorPane(env.state)) {
138
- lines.push(...renderEditorBlock(env, editorCaption(env.state)));
139
- return;
140
- }
141
- const preview = previewForSelection(question, rows[env.state.selectedIndex]);
142
- if (preview) lines.push(...renderPreviewBlock(env, preview));
143
- }
144
-
145
- function renderPaneRows(
146
- env: RenderEnv,
147
- question: NormalizedStructuredQuestion,
148
- rows: InteractiveRow[],
149
- ): string[] {
150
- return renderRows(env, question, rows);
151
- }
152
-
153
- /** Render a single interactive row, or its inline editor lines when the row
154
- * is an active other/discuss input and the editor is not rendered separately. */
155
- function renderInlineEditorLine(
156
- env: RenderEnv,
157
- prefix: string,
158
- row: InteractiveRow,
159
- out: string[],
160
- ): boolean {
161
- if (usesSeparateEditorPane(env.state)) return false;
162
- const lines = inlineStructuredRowLines({
163
- width: env.width,
164
- theme: env.theme,
165
- state: env.state,
166
- editor: env.editor,
167
- row,
168
- prefix,
169
- });
170
- if (!lines) return false;
171
- const continuation = " ".repeat(visibleWidth(prefix));
172
- for (const [i, line] of lines.entries()) {
173
- out.push(`${i === 0 ? prefix : continuation}${line}`);
174
- }
175
- return true;
176
- }
177
-
178
- function renderRows(
179
- env: RenderEnv,
180
- question: NormalizedStructuredQuestion,
181
- rows: InteractiveRow[],
182
- ): string[] {
183
- const out: string[] = [];
184
- for (const [index, row] of rows.entries()) {
185
- const active = env.state.selectedIndex === index;
186
- const prefix = active ? env.theme.fg("accent", "> ") : " ";
187
- if (renderInlineEditorLine(env, prefix, row, out)) continue;
188
- addWrapped(out, env, prefix, rowLabel(env, question, row, active));
189
- const description = rowDescription(question, row);
190
- if (description) renderRowDescription(out, env, description);
191
- }
192
- return out;
193
- }
194
-
195
- function rowLabel(
196
- env: RenderEnv,
197
- question: NormalizedStructuredQuestion,
198
- row: InteractiveRow,
199
- active: boolean,
200
- ): string {
201
- const selected = selectedIndexesForQuestion(env.flow, env.state, question);
202
- if (row.kind === "option") {
203
- const option = question.options[row.optionIndex];
204
- const recommended = question.recommendedIndexes.includes(row.optionIndex);
205
- const noteMarker = visibleNoteMarker({
206
- flow: env.flow,
207
- state: env.state,
208
- question,
209
- row,
210
- active,
211
- });
212
- const baseLabel = `${decorateOption(option.label, recommended)}${noteMarker ? ` ${NOTE_MARKER}` : ""}`;
213
- if (question.multi) {
214
- const checked = selected.includes(row.optionIndex) ? "[x]" : "[ ]";
215
- return env.theme.fg("text", `${checked} ${baseLabel}`);
216
- }
217
- return env.theme.fg("text", `${row.optionIndex + 1}. ${baseLabel}`);
218
- }
219
- if (row.kind === "other")
220
- return env.theme.fg("text", structuredRowLabel(env.flow, question, row));
221
- return env.theme.fg("text", structuredRowLabel(env.flow, question, row));
222
- }
223
-
224
- function rowDescription(
225
- question: NormalizedStructuredQuestion,
226
- row: InteractiveRow,
227
- ): string | undefined {
228
- if (row.kind === "option") return question.options[row.optionIndex].description;
229
- return undefined;
230
- }
231
-
232
- function renderRowDescription(out: string[], env: RenderEnv, description: string): void {
233
- const descLines = renderMarkdown(description, env.width - 5, env.theme, {
234
- paddingX: 0,
235
- defaultColor: "muted",
236
- });
237
- for (const line of descLines) {
238
- out.push(` ${line}`);
239
- }
240
- }
241
-
242
- function renderReviewAnswer(out: string[], env: RenderEnv, line: string): void {
243
- const reviewLines = renderMarkdown(line, env.width - 3, env.theme, {
244
- paddingX: 0,
245
- defaultColor: "text",
246
- });
247
- for (const reviewLine of reviewLines) {
248
- out.push(` ${reviewLine}`);
249
- }
250
- }
251
-
252
- function renderPreviewPane(
253
- width: number,
254
- theme: RenderEnv["theme"],
255
- preview: string | undefined,
256
- ): string[] {
257
- const out: string[] = [];
258
- const push = (text = "") => out.push(text);
259
- push(theme.fg("accent", " Preview"));
260
- push("");
261
- if (!preview) {
262
- push(theme.fg("muted", " No preview for the current selection."));
263
- return out;
264
- }
265
- out.push(...renderMarkdownPreview(preview, width, theme));
266
- return out;
267
- }
268
-
269
- function renderPreviewBlock(env: RenderEnv, preview: string): string[] {
270
- const out: string[] = [];
271
- out.push("");
272
- out.push(env.theme.fg("accent", " Preview:"));
273
- out.push(...renderMarkdownPreview(preview, env.width, env.theme));
274
- return out;
275
- }
276
-
277
- function previewForSelection(
278
- question: NormalizedStructuredQuestion,
279
- row: InteractiveRow | undefined,
280
- ): string | undefined {
281
- return row?.kind === "option" ? question.options[row.optionIndex].preview : undefined;
282
- }
283
-
284
- function renderReview(env: RenderEnv): string[] {
285
- const out: string[] = [];
286
- const add = (text: string) => out.push(truncateToWidth(text, env.width));
287
- add(env.theme.fg("accent", " Review answers:"));
288
- add("");
289
- for (const question of env.flow.questions) {
290
- const answer = env.flow.getAnswer(question.id);
291
- const answerLines = answer
292
- ? formatReviewLines(question, answer)
293
- : [question.required ? "(no answer)" : "(skipped)"];
294
- add(env.theme.fg("muted", ` ${question.header}:`));
295
- for (const line of answerLines) renderReviewAnswer(out, env, line);
296
- }
297
- add("");
298
- if (env.flow.showSkip) {
299
- add(
300
- env.theme.fg(
301
- env.flow.allRequiredAnswered() ? "success" : "warning",
302
- " Press Enter to submit • s to skip",
303
- ),
304
- );
305
- } else {
306
- add(
307
- env.theme.fg(
308
- env.flow.allRequiredAnswered() ? "success" : "warning",
309
- " Press Enter to submit",
310
- ),
311
- );
312
- }
313
- return out;
314
- }
315
-
316
- function addWrapped(lines: string[], env: RenderEnv, prefix: string, text: string): void {
317
- const prefixWidth = visibleWidth(prefix);
318
- const contentWidth = Math.max(1, env.width - prefixWidth);
319
- const continuationPrefix = " ".repeat(prefixWidth);
320
- for (const [index, line] of wrapTextWithAnsi(text, contentWidth).entries()) {
321
- lines.push(`${index === 0 ? prefix : continuationPrefix}${line}`);
322
- }
323
- }
package/src/render.ts DELETED
@@ -1,95 +0,0 @@
1
- // Custom renderCall / renderResult for the `ask_user` tool. Keeps the in-line
2
- // session transcript readable: a one-line "asking N questions: …" header on
3
- // the call, and a compact ✓ / cancelled / aborted summary on the result.
4
-
5
- import type { Theme } from "@earendil-works/pi-coding-agent";
6
- import { Text } from "@earendil-works/pi-tui";
7
- import { formatSummaryBody } from "./format.ts";
8
- import { ASK_USER_ERROR_MARKER } from "./result.ts";
9
- import type { AskUserDetails, NormalizedQuestion } from "./types.ts";
10
-
11
- export function renderAskUserCall(args: unknown, theme: Theme): Text {
12
- const headers = extractHeadersFromArgs(args);
13
- const count = headers.length;
14
- let text = theme.fg("toolTitle", theme.bold("ask_user "));
15
- text += theme.fg("muted", `${count || "?"} question${count === 1 ? "" : "s"}`);
16
- if (count > 0) {
17
- text += theme.fg("dim", ` (${headers.join(", ")})`);
18
- }
19
- return new Text(text, 0, 0);
20
- }
21
-
22
- export function renderAskUserResult(
23
- result: { details?: unknown; content: { type: string; text?: string }[] },
24
- theme: Theme,
25
- ): Text {
26
- if (isErrorDetails(result.details)) {
27
- return new Text(theme.fg("error", result.content[0]?.text ?? "Error"), 0, 0);
28
- }
29
- const details = coerceDetails(result.details);
30
- if (!details) {
31
- const fallback = result.content[0];
32
- return new Text(fallback?.type === "text" ? (fallback.text ?? "") : "", 0, 0);
33
- }
34
- if (details.terminalState === "skipped") {
35
- return new Text(
36
- `${theme.fg("dim", "Skipped")}\n${formatSubmittedSummary(details, theme)}`,
37
- 0,
38
- 0,
39
- );
40
- }
41
- if (details.terminalState === "cancelled") {
42
- return new Text(theme.fg("warning", "Cancelled"), 0, 0);
43
- }
44
- if (details.terminalState === "aborted") {
45
- return new Text(theme.fg("error", "Aborted"), 0, 0);
46
- }
47
- return new Text(formatSubmittedSummary(details, theme), 0, 0);
48
- }
49
-
50
- function isErrorDetails(details: unknown): boolean {
51
- return (
52
- !!details &&
53
- typeof details === "object" &&
54
- (details as Record<string, unknown>)[ASK_USER_ERROR_MARKER] === true
55
- );
56
- }
57
-
58
- function extractHeadersFromArgs(args: unknown): string[] {
59
- if (!args || typeof args !== "object") return [];
60
- const questions = (args as { questions?: unknown }).questions;
61
- if (!Array.isArray(questions)) return [];
62
- return questions
63
- .map((question) =>
64
- question && typeof question === "object"
65
- ? (question as { header?: unknown }).header
66
- : undefined,
67
- )
68
- .filter((header): header is string => typeof header === "string" && header.length > 0);
69
- }
70
-
71
- function coerceDetails(details: unknown): AskUserDetails | null {
72
- if (!details || typeof details !== "object") return null;
73
- const obj = details as { terminalState?: unknown; questions?: unknown; answers?: unknown };
74
- if (typeof obj.terminalState !== "string") return null;
75
- if (!Array.isArray(obj.questions) || !Array.isArray(obj.answers)) return null;
76
- return obj as AskUserDetails;
77
- }
78
-
79
- function formatSubmittedSummary(details: AskUserDetails, theme: Theme): string {
80
- const byId = new Map(details.answers.map((answer) => [answer.questionId, answer]));
81
- return details.questions
82
- .map((question) => formatLine(question, byId.get(question.id), theme))
83
- .join("\n");
84
- }
85
-
86
- function formatLine(
87
- question: NormalizedQuestion,
88
- answer: AskUserDetails["answers"][number] | undefined,
89
- theme: Theme,
90
- ): string {
91
- if (!answer) {
92
- return `${theme.fg("warning", "○ ")}${theme.fg("muted", question.header)}: (no answer)`;
93
- }
94
- return `${theme.fg("success", "✓ ")}${theme.fg("accent", question.header)}: ${theme.fg("text", formatSummaryBody(question, answer))}`;
95
- }