@pi-archimedes/ask 1.3.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/src/picker.ts ADDED
@@ -0,0 +1,308 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Editor,
4
+ Markdown,
5
+ type EditorTheme,
6
+ type MarkdownTheme,
7
+ Key,
8
+ matchesKey,
9
+ truncateToWidth,
10
+ visibleWidth,
11
+ } from "@earendil-works/pi-tui";
12
+ import {
13
+ OTHER_OPTION,
14
+ appendRecommendedTagToOptionLabels,
15
+ buildSingleSelectionResult,
16
+ type AskOption,
17
+ type AskSelection,
18
+ } from "./selection";
19
+ import { getLinearCursorIndexFromEditor } from "./cursor";
20
+ import { INLINE_NOTE_WRAP_PADDING, buildWrappedOptionLabelWithInlineNote } from "./note";
21
+ import { appendWrappedTextLines } from "./wrap";
22
+
23
+ interface SingleQuestionInput {
24
+ question: string;
25
+ description?: string;
26
+ options: AskOption[];
27
+ recommended?: number;
28
+ }
29
+
30
+ interface InlineSelectionResult {
31
+ cancelled: boolean;
32
+ selectedOption?: string;
33
+ note?: string;
34
+ }
35
+
36
+ function resolveInitialCursorIndexFromRecommendedOption(
37
+ recommendedOptionIndex: number | undefined,
38
+ optionCount: number,
39
+ ): number {
40
+ if (recommendedOptionIndex == null) return 0;
41
+ if (recommendedOptionIndex < 0 || recommendedOptionIndex >= optionCount) return 0;
42
+ return recommendedOptionIndex;
43
+ }
44
+
45
+ export async function askSingleQuestionWithInlineNote(
46
+ ui: ExtensionUIContext,
47
+ questionInput: SingleQuestionInput,
48
+ options?: { overlay?: boolean },
49
+ ): Promise<AskSelection> {
50
+ const baseOptionLabels = questionInput.options.map((option) => option.label);
51
+ const optionLabelsWithRecommendedTag = appendRecommendedTagToOptionLabels(
52
+ baseOptionLabels,
53
+ questionInput.recommended,
54
+ );
55
+ const selectableOptionLabels = [...optionLabelsWithRecommendedTag, OTHER_OPTION];
56
+ const initialCursorIndex = resolveInitialCursorIndexFromRecommendedOption(
57
+ questionInput.recommended,
58
+ optionLabelsWithRecommendedTag.length,
59
+ );
60
+
61
+ const result = await ui.custom<InlineSelectionResult>((tui, theme, _keybindings, done) => {
62
+ let cursorOptionIndex = initialCursorIndex;
63
+ let isNoteEditorOpen = false;
64
+ let cachedRenderedLines: string[] | undefined;
65
+ let cachedRenderedWidth: number | undefined;
66
+ const noteByOptionIndex = new Map<number, string>();
67
+
68
+ const editorTheme: EditorTheme = {
69
+ borderColor: (text) => theme.fg("accent", text),
70
+ selectList: {
71
+ selectedPrefix: (text) => theme.fg("accent", text),
72
+ selectedText: (text) => theme.fg("accent", text),
73
+ description: (text) => theme.fg("muted", text),
74
+ scrollInfo: (text) => theme.fg("dim", text),
75
+ noMatch: (text) => theme.fg("warning", text),
76
+ },
77
+ };
78
+ const noteEditor = new Editor(tui, editorTheme);
79
+ const markdownTheme: MarkdownTheme = {
80
+ heading: (text) => theme.fg("mdHeading", text),
81
+ link: (text) => theme.fg("mdLink", text),
82
+ linkUrl: (text) => theme.fg("mdLinkUrl", text),
83
+ code: (text) => theme.fg("mdCode", text),
84
+ codeBlock: (text) => theme.fg("mdCodeBlock", text),
85
+ codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
86
+ quote: (text) => theme.fg("mdQuote", text),
87
+ quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
88
+ hr: (text) => theme.fg("mdHr", text),
89
+ listBullet: (text) => theme.fg("mdListBullet", text),
90
+ bold: (text) => theme.bold(text),
91
+ italic: (text) => theme.italic(text),
92
+ strikethrough: (text) => theme.strikethrough(text),
93
+ underline: (text) => theme.underline(text),
94
+ };
95
+ const questionDescriptionMarkdown =
96
+ questionInput.description && questionInput.description.trim().length > 0
97
+ ? new Markdown(questionInput.description, 0, 0, markdownTheme, {
98
+ color: (text) => theme.fg("muted", text),
99
+ })
100
+ : undefined;
101
+
102
+ const requestUiRerender = () => {
103
+ cachedRenderedLines = undefined;
104
+ cachedRenderedWidth = undefined;
105
+ tui.requestRender();
106
+ };
107
+
108
+ const getRawNoteForOption = (optionIndex: number): string => noteByOptionIndex.get(optionIndex) ?? "";
109
+ const getTrimmedNoteForOption = (optionIndex: number): string => getRawNoteForOption(optionIndex).trim();
110
+
111
+ const loadCurrentNoteIntoEditor = () => {
112
+ noteEditor.setText(getRawNoteForOption(cursorOptionIndex));
113
+ };
114
+
115
+ const openNoteEditorForCurrentOption = () => {
116
+ if (isNoteEditorOpen) return;
117
+ isNoteEditorOpen = true;
118
+ loadCurrentNoteIntoEditor();
119
+ };
120
+
121
+ const saveCurrentNoteFromEditor = (value: string) => {
122
+ noteByOptionIndex.set(cursorOptionIndex, value);
123
+ };
124
+
125
+ const submitCurrentSelection = (selectedOptionLabel: string, note: string) => {
126
+ done({
127
+ cancelled: false,
128
+ selectedOption: selectedOptionLabel,
129
+ note,
130
+ });
131
+ };
132
+
133
+ noteEditor.onChange = (value) => {
134
+ saveCurrentNoteFromEditor(value);
135
+ requestUiRerender();
136
+ };
137
+
138
+ noteEditor.onSubmit = (value) => {
139
+ saveCurrentNoteFromEditor(value);
140
+ const selectedOptionLabel = selectableOptionLabels[cursorOptionIndex] ?? "";
141
+ const trimmedNote = value.trim();
142
+
143
+ if (selectedOptionLabel === OTHER_OPTION && !trimmedNote) {
144
+ requestUiRerender();
145
+ return;
146
+ }
147
+
148
+ submitCurrentSelection(selectedOptionLabel, trimmedNote);
149
+ };
150
+
151
+ const render = (width: number): string[] => {
152
+ if (cachedRenderedLines && cachedRenderedWidth === width) return cachedRenderedLines;
153
+
154
+ const renderedLines: string[] = [];
155
+ const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
156
+
157
+ addLine(theme.fg("accent", "─".repeat(width)));
158
+ appendWrappedTextLines(renderedLines, questionInput.question, width, {
159
+ indent: 1,
160
+ formatLine: (line) => theme.fg("text", line),
161
+ });
162
+ if (questionDescriptionMarkdown) {
163
+ renderedLines.push("");
164
+ const descriptionLines = questionDescriptionMarkdown.render(Math.max(1, width - 1));
165
+ for (const descriptionLine of descriptionLines) {
166
+ addLine(` ${descriptionLine}`);
167
+ }
168
+ }
169
+ renderedLines.push("");
170
+
171
+ const activeEditingCursorIndex = isNoteEditorOpen
172
+ ? getLinearCursorIndexFromEditor(noteEditor)
173
+ : undefined;
174
+ for (let optionIndex = 0; optionIndex < selectableOptionLabels.length; optionIndex++) {
175
+ const optionLabel = selectableOptionLabels[optionIndex] ?? "";
176
+ const isCursorOption = optionIndex === cursorOptionIndex;
177
+ const isEditingThisOption = isNoteEditorOpen && isCursorOption;
178
+ const cursorPrefixText = isCursorOption ? "→ " : " ";
179
+ const cursorPrefix = isCursorOption ? theme.fg("accent", cursorPrefixText) : cursorPrefixText;
180
+ const bullet = isCursorOption ? "●" : "○";
181
+ const markerText = `${bullet} `;
182
+ const optionColor = isCursorOption ? "accent" : "text";
183
+ const prefixWidth = visibleWidth(cursorPrefixText) + visibleWidth(markerText);
184
+ const wrappedInlineLabelLines = buildWrappedOptionLabelWithInlineNote(
185
+ optionLabel,
186
+ getRawNoteForOption(optionIndex),
187
+ isEditingThisOption,
188
+ Math.max(1, width - prefixWidth),
189
+ INLINE_NOTE_WRAP_PADDING,
190
+ isEditingThisOption ? activeEditingCursorIndex : undefined,
191
+ isEditingThisOption,
192
+ );
193
+ const continuationPrefix = " ".repeat(prefixWidth);
194
+ addLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`);
195
+ for (const wrappedLine of wrappedInlineLabelLines.slice(1)) {
196
+ addLine(`${continuationPrefix}${theme.fg(optionColor, wrappedLine)}`);
197
+ }
198
+ }
199
+
200
+ renderedLines.push("");
201
+
202
+ if (isNoteEditorOpen) {
203
+ addLine(theme.fg("dim", " Typing note inline • Enter submit • Tab/Esc stop editing"));
204
+ } else if (getTrimmedNoteForOption(cursorOptionIndex).length > 0) {
205
+ addLine(theme.fg("dim", " ↑↓ move • Enter submit • Tab edit note • Esc cancel"));
206
+ } else {
207
+ addLine(theme.fg("dim", " ↑↓ move • Enter submit • Tab add note • Esc cancel"));
208
+ }
209
+
210
+ addLine(theme.fg("accent", "─".repeat(width)));
211
+ cachedRenderedLines = renderedLines;
212
+ cachedRenderedWidth = width;
213
+ return renderedLines;
214
+ };
215
+
216
+ const handleInput = (data: string) => {
217
+ if (matchesKey(data, Key.ctrl("c"))) {
218
+ done({ cancelled: true });
219
+ return;
220
+ }
221
+
222
+ if (isNoteEditorOpen) {
223
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
224
+ isNoteEditorOpen = false;
225
+ requestUiRerender();
226
+ return;
227
+ }
228
+
229
+ if (
230
+ (matchesKey(data, Key.up) || matchesKey(data, Key.down)) &&
231
+ getTrimmedNoteForOption(cursorOptionIndex).length === 0
232
+ ) {
233
+ isNoteEditorOpen = false;
234
+ } else {
235
+ noteEditor.handleInput(data);
236
+ requestUiRerender();
237
+ return;
238
+ }
239
+ }
240
+
241
+ if (matchesKey(data, Key.up)) {
242
+ cursorOptionIndex = Math.max(0, cursorOptionIndex - 1);
243
+ if (selectableOptionLabels[cursorOptionIndex] === OTHER_OPTION) {
244
+ openNoteEditorForCurrentOption();
245
+ }
246
+ requestUiRerender();
247
+ return;
248
+ }
249
+ if (matchesKey(data, Key.down)) {
250
+ cursorOptionIndex = Math.min(selectableOptionLabels.length - 1, cursorOptionIndex + 1);
251
+ if (selectableOptionLabels[cursorOptionIndex] === OTHER_OPTION) {
252
+ openNoteEditorForCurrentOption();
253
+ }
254
+ requestUiRerender();
255
+ return;
256
+ }
257
+
258
+ if (matchesKey(data, Key.tab)) {
259
+ openNoteEditorForCurrentOption();
260
+ requestUiRerender();
261
+ return;
262
+ }
263
+
264
+ if (matchesKey(data, Key.enter)) {
265
+ const selectedOptionLabel = selectableOptionLabels[cursorOptionIndex] ?? "";
266
+ const trimmedNote = getTrimmedNoteForOption(cursorOptionIndex);
267
+
268
+ if (selectedOptionLabel === OTHER_OPTION && !trimmedNote) {
269
+ isNoteEditorOpen = true;
270
+ loadCurrentNoteIntoEditor();
271
+ requestUiRerender();
272
+ return;
273
+ }
274
+
275
+ submitCurrentSelection(selectedOptionLabel, trimmedNote);
276
+ return;
277
+ }
278
+
279
+ if (matchesKey(data, Key.escape)) {
280
+ done({ cancelled: true });
281
+ return;
282
+ }
283
+
284
+ if (selectableOptionLabels[cursorOptionIndex] === OTHER_OPTION) {
285
+ openNoteEditorForCurrentOption();
286
+ noteEditor.handleInput(data);
287
+ requestUiRerender();
288
+ return;
289
+ }
290
+ };
291
+
292
+ return {
293
+ focused: true,
294
+ render,
295
+ invalidate: () => {
296
+ cachedRenderedLines = undefined;
297
+ cachedRenderedWidth = undefined;
298
+ },
299
+ handleInput,
300
+ };
301
+ }, options?.overlay ? { overlay: true } : undefined);
302
+
303
+ if (!result || result.cancelled || !result.selectedOption) {
304
+ return { selectedOptions: [] };
305
+ }
306
+
307
+ return buildSingleSelectionResult(result.selectedOption, result.note);
308
+ }
@@ -0,0 +1,99 @@
1
+ export const OTHER_OPTION = "Other (type your own)";
2
+ const RECOMMENDED_OPTION_TAG = " (Recommended)";
3
+
4
+ export interface AskOption {
5
+ label: string;
6
+ }
7
+
8
+ export interface AskQuestion {
9
+ id: string;
10
+ question: string;
11
+ description?: string;
12
+ options: AskOption[];
13
+ multi?: boolean;
14
+ recommended?: number;
15
+ }
16
+
17
+ export interface AskSelection {
18
+ selectedOptions: string[];
19
+ customInput?: string;
20
+ }
21
+
22
+ export function appendRecommendedTagToOptionLabels(
23
+ optionLabels: string[],
24
+ recommendedOptionIndex?: number,
25
+ ): string[] {
26
+ if (
27
+ recommendedOptionIndex == null ||
28
+ recommendedOptionIndex < 0 ||
29
+ recommendedOptionIndex >= optionLabels.length
30
+ ) {
31
+ return optionLabels;
32
+ }
33
+
34
+ return optionLabels.map((optionLabel, optionIndex) => {
35
+ if (optionIndex !== recommendedOptionIndex) return optionLabel;
36
+ if (optionLabel.endsWith(RECOMMENDED_OPTION_TAG)) return optionLabel;
37
+ return `${optionLabel}${RECOMMENDED_OPTION_TAG}`;
38
+ });
39
+ }
40
+
41
+ function removeRecommendedTagFromOptionLabel(optionLabel: string): string {
42
+ if (!optionLabel.endsWith(RECOMMENDED_OPTION_TAG)) {
43
+ return optionLabel;
44
+ }
45
+ return optionLabel.slice(0, -RECOMMENDED_OPTION_TAG.length);
46
+ }
47
+
48
+ export function buildSingleSelectionResult(selectedOptionLabel: string, note?: string): AskSelection {
49
+ const normalizedSelectedOption = removeRecommendedTagFromOptionLabel(selectedOptionLabel);
50
+ const normalizedNote = note?.trim();
51
+
52
+ if (normalizedSelectedOption === OTHER_OPTION) {
53
+ if (normalizedNote) {
54
+ return { selectedOptions: [], customInput: normalizedNote };
55
+ }
56
+ return { selectedOptions: [] };
57
+ }
58
+
59
+ if (normalizedNote) {
60
+ return { selectedOptions: [`${normalizedSelectedOption} - ${normalizedNote}`] };
61
+ }
62
+
63
+ return { selectedOptions: [normalizedSelectedOption] };
64
+ }
65
+
66
+ export function buildMultiSelectionResult(
67
+ optionLabels: string[],
68
+ selectedOptionIndexes: number[],
69
+ optionNotes: string[],
70
+ otherOptionIndex: number,
71
+ ): AskSelection {
72
+ const selectedOptionSet = new Set(selectedOptionIndexes);
73
+ const selectedOptions: string[] = [];
74
+ let customInput: string | undefined;
75
+
76
+ for (let optionIndex = 0; optionIndex < optionLabels.length; optionIndex++) {
77
+ if (!selectedOptionSet.has(optionIndex)) continue;
78
+
79
+ const optionLabel = removeRecommendedTagFromOptionLabel(optionLabels[optionIndex] ?? "");
80
+ const optionNote = optionNotes[optionIndex]?.trim();
81
+
82
+ if (optionIndex === otherOptionIndex) {
83
+ if (optionNote) customInput = optionNote;
84
+ continue;
85
+ }
86
+
87
+ if (optionNote) {
88
+ selectedOptions.push(`${optionLabel} - ${optionNote}`);
89
+ } else {
90
+ selectedOptions.push(optionLabel);
91
+ }
92
+ }
93
+
94
+ if (customInput) {
95
+ return { selectedOptions, customInput };
96
+ }
97
+
98
+ return { selectedOptions };
99
+ }
package/src/wrap.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+
3
+ interface AppendWrappedTextOptions {
4
+ indent?: number;
5
+ formatLine?: (line: string) => string;
6
+ }
7
+
8
+ function normalizeMultilineText(text: string): string[] {
9
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
10
+ const lines = normalized.split("\n");
11
+ return lines.length > 0 ? lines : [""];
12
+ }
13
+
14
+ export function appendWrappedTextLines(
15
+ renderedLines: string[],
16
+ text: string,
17
+ width: number,
18
+ options: AppendWrappedTextOptions = {},
19
+ ): void {
20
+ const safeWidth = Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 1;
21
+ const indent = Number.isFinite(options.indent) ? Math.max(0, Math.floor(options.indent ?? 0)) : 0;
22
+ const prefix = " ".repeat(indent);
23
+ const wrapWidth = Math.max(1, safeWidth - indent);
24
+ const formatLine = options.formatLine ?? ((line: string) => line);
25
+
26
+ for (const sourceLine of normalizeMultilineText(text)) {
27
+ const wrappedLines = wrapTextWithAnsi(sourceLine, wrapWidth);
28
+ const safeWrappedLines = wrappedLines.length > 0 ? wrappedLines : [""];
29
+ for (const wrappedLine of safeWrappedLines) {
30
+ renderedLines.push(truncateToWidth(`${prefix}${formatLine(wrappedLine)}`, safeWidth));
31
+ }
32
+ }
33
+ }