@howaboua/pi-ask 0.0.5 → 0.0.7

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.
@@ -0,0 +1,145 @@
1
+ import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
+ import type { Editor } from "@earendil-works/pi-tui";
3
+ import type {
4
+ AskPrompt,
5
+ AskResponse,
6
+ PromptChoice,
7
+ PromptState,
8
+ } from "./contracts.js";
9
+ import { promptStatesToResponses } from "./state.js";
10
+
11
+ export type EditingKind = "other" | "comment";
12
+
13
+ export interface AskUiState {
14
+ tab: number;
15
+ focus: number;
16
+ editing: EditingKind | null;
17
+ }
18
+
19
+ interface EditingInputOptions {
20
+ advance: () => void;
21
+ editor: Editor;
22
+ keybindings: KeybindingsManager;
23
+ refresh: () => void;
24
+ saveEditing: (submittedText?: string) => void;
25
+ state: AskUiState;
26
+ }
27
+
28
+ export function handleAskEditingInput(
29
+ data: string,
30
+ {
31
+ advance,
32
+ editor,
33
+ keybindings,
34
+ refresh,
35
+ saveEditing,
36
+ state,
37
+ }: EditingInputOptions,
38
+ ): void {
39
+ if (keybindings.matches(data, "tui.input.tab")) {
40
+ saveEditing(editor.getText());
41
+ state.editing = null;
42
+ advance();
43
+ return;
44
+ }
45
+ if (keybindings.matches(data, "tui.select.cancel")) {
46
+ state.editing = null;
47
+ refresh();
48
+ return;
49
+ }
50
+ editor.handleInput(data);
51
+ refresh();
52
+ }
53
+
54
+ interface PromptInputOptions {
55
+ advanceWithDefault: () => void;
56
+ choices: () => PromptChoice[];
57
+ count: () => number;
58
+ keybindings: KeybindingsManager;
59
+ pick: (index: number) => void;
60
+ refresh: () => void;
61
+ selectOther: () => void;
62
+ startEditingComment: () => void;
63
+ state: AskUiState;
64
+ }
65
+
66
+ export function handleAskPromptInput(
67
+ data: string,
68
+ {
69
+ advanceWithDefault,
70
+ choices,
71
+ count,
72
+ keybindings,
73
+ pick,
74
+ refresh,
75
+ selectOther,
76
+ startEditingComment,
77
+ state,
78
+ }: PromptInputOptions,
79
+ ): void {
80
+ if (keybindings.matches(data, "tui.select.up")) {
81
+ state.focus = Math.max(0, state.focus - 1);
82
+ refresh();
83
+ return;
84
+ }
85
+ if (keybindings.matches(data, "tui.select.down")) {
86
+ state.focus = Math.min(count() - 1, state.focus + 1);
87
+ refresh();
88
+ return;
89
+ }
90
+ if (keybindings.matches(data, "tui.input.tab")) {
91
+ advanceWithDefault();
92
+ return;
93
+ }
94
+ if (!keybindings.matches(data, "tui.select.confirm")) return;
95
+ if (state.focus === choices().length) {
96
+ selectOther();
97
+ return;
98
+ }
99
+ if (state.focus === choices().length + 1) {
100
+ startEditingComment();
101
+ return;
102
+ }
103
+ pick(state.focus);
104
+ }
105
+
106
+ interface NavigationInputOptions {
107
+ done: (responses: AskResponse[] | null) => void;
108
+ isReview: () => boolean;
109
+ keybindings: KeybindingsManager;
110
+ promptStates: PromptState[];
111
+ prompts: AskPrompt[];
112
+ setTab: (next: number) => void;
113
+ state: AskUiState;
114
+ }
115
+
116
+ export function handleAskNavigationInput(
117
+ data: string,
118
+ {
119
+ done,
120
+ isReview,
121
+ keybindings,
122
+ promptStates,
123
+ prompts,
124
+ setTab,
125
+ state,
126
+ }: NavigationInputOptions,
127
+ ): boolean {
128
+ if (keybindings.matches(data, "tui.select.cancel")) {
129
+ done(null);
130
+ return true;
131
+ }
132
+ if (keybindings.matches(data, "tui.editor.cursorLeft")) {
133
+ setTab(state.tab - 1);
134
+ return true;
135
+ }
136
+ if (keybindings.matches(data, "tui.editor.cursorRight")) {
137
+ setTab(state.tab + 1);
138
+ return true;
139
+ }
140
+ if (!isReview()) return false;
141
+ if (keybindings.matches(data, "tui.select.confirm")) {
142
+ done(promptStatesToResponses(prompts, promptStates));
143
+ }
144
+ return true;
145
+ }
@@ -0,0 +1,276 @@
1
+ import {
2
+ type ExtensionContext,
3
+ type KeybindingsManager,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { Editor, wrapTextWithAnsi } from "@earendil-works/pi-tui";
6
+ import { OTHER_OPTION_LABEL } from "./constants.js";
7
+ import type { AskPrompt, PromptChoice, PromptState } from "./contracts.js";
8
+ import type { AskUiState, EditingKind } from "./tui-input.js";
9
+
10
+ type AskTheme = ExtensionContext["ui"]["theme"];
11
+ type AskKeybinding = Parameters<KeybindingsManager["getKeys"]>[0];
12
+ type AddLine = (line?: string) => void;
13
+ interface RenderTabsOptions {
14
+ handoff: boolean;
15
+ prompts: AskPrompt[];
16
+ responded: (index: number) => boolean;
17
+ steering: boolean;
18
+ tab: number;
19
+ theme: AskTheme;
20
+ }
21
+
22
+ interface RenderReviewOptions {
23
+ add: AddLine;
24
+ handoff: boolean;
25
+ keybindings: KeybindingsManager;
26
+ promptStates: PromptState[];
27
+ prompts: AskPrompt[];
28
+ theme: AskTheme;
29
+ width: number;
30
+ }
31
+
32
+ interface RenderChoiceOptions {
33
+ add: AddLine;
34
+ checked: boolean | undefined;
35
+ choice: PromptChoice;
36
+ selected: boolean;
37
+ theme: AskTheme;
38
+ width: number;
39
+ }
40
+
41
+ interface RenderPromptOptions {
42
+ add: AddLine;
43
+ editor: Editor;
44
+ handoff: boolean;
45
+ isEditing: EditingKind | null;
46
+ keybindings: KeybindingsManager;
47
+ choices: PromptChoice[];
48
+ prompt: AskPrompt;
49
+ promptState: PromptState | undefined;
50
+ state: AskUiState;
51
+ theme: AskTheme;
52
+ width: number;
53
+ }
54
+
55
+ function addWrapped(
56
+ add: AddLine,
57
+ text: string,
58
+ width: number,
59
+ indent = "",
60
+ ): void {
61
+ for (const line of wrapTextWithAnsi(
62
+ text,
63
+ Math.max(1, width - indent.length),
64
+ )) {
65
+ add(`${indent}${line}`);
66
+ }
67
+ }
68
+
69
+ function renderKeyHint(
70
+ theme: AskTheme,
71
+ keybindings: KeybindingsManager,
72
+ action: AskKeybinding,
73
+ description: string,
74
+ ): string {
75
+ return (
76
+ theme.fg("dim", keybindings.getKeys(action).join("/")) +
77
+ theme.fg("muted", ` ${description}`)
78
+ );
79
+ }
80
+ export function renderAskTabs({
81
+ handoff,
82
+ prompts,
83
+ responded,
84
+ steering,
85
+ tab,
86
+ theme,
87
+ }: RenderTabsOptions): string {
88
+ const tabs = Array.from({ length: prompts.length + 1 }, (_, index) => {
89
+ const active = index === tab;
90
+ const label =
91
+ index === prompts.length
92
+ ? handoff
93
+ ? "Resume"
94
+ : "Review"
95
+ : `${responded(index) ? "■" : "□"} ${index + 1}`;
96
+ return active
97
+ ? theme.bg("selectedBg", theme.fg("text", ` ${label} `))
98
+ : theme.fg("muted", ` ${label} `);
99
+ }).join(" ");
100
+ return ` ${tabs}${steering ? theme.fg("muted", " Agent continues while you decide") : ""}`;
101
+ }
102
+
103
+ export function renderAskReview({
104
+ add,
105
+ handoff,
106
+ keybindings,
107
+ promptStates,
108
+ prompts,
109
+ theme,
110
+ width,
111
+ }: RenderReviewOptions): void {
112
+ add(theme.fg("accent", handoff ? " Resume agent" : " Review"));
113
+ add();
114
+ prompts.forEach((prompt, index) => {
115
+ const promptState = promptStates[index];
116
+ const selections = promptState?.selections ?? [];
117
+ addWrapped(
118
+ add,
119
+ theme.fg("muted", `${index + 1}. ${prompt.title}`),
120
+ width,
121
+ " ",
122
+ );
123
+ addWrapped(
124
+ add,
125
+ theme.fg("text", selections.join(", ") || "No selection"),
126
+ width,
127
+ " ",
128
+ );
129
+ if (promptState?.comment.trim()) {
130
+ addWrapped(
131
+ add,
132
+ theme.fg("muted", `Comment: ${promptState.comment.trim()}`),
133
+ width,
134
+ " ",
135
+ );
136
+ }
137
+ });
138
+ add();
139
+ addWrapped(
140
+ add,
141
+ " " +
142
+ renderKeyHint(
143
+ theme,
144
+ keybindings,
145
+ "tui.select.confirm",
146
+ handoff ? "return control" : "submit",
147
+ ) +
148
+ " • " +
149
+ renderKeyHint(
150
+ theme,
151
+ keybindings,
152
+ "tui.editor.cursorLeft",
153
+ "previous prompt",
154
+ ) +
155
+ " • " +
156
+ renderKeyHint(
157
+ theme,
158
+ keybindings,
159
+ "tui.editor.cursorRight",
160
+ "next prompt",
161
+ ) +
162
+ " • " +
163
+ renderKeyHint(theme, keybindings, "tui.select.cancel", "dismiss"),
164
+ width,
165
+ );
166
+ }
167
+
168
+ function renderAskChoice({
169
+ add,
170
+ checked,
171
+ choice,
172
+ selected,
173
+ theme,
174
+ width,
175
+ }: RenderChoiceOptions): void {
176
+ add(
177
+ `${selected ? theme.fg("accent", "> ") : " "}${checked ? theme.fg("success", "✓ ") : " "}${theme.fg(selected ? "accent" : "text", choice.label)}`,
178
+ );
179
+ if (choice.description) {
180
+ addWrapped(add, theme.fg("muted", choice.description), width, " ");
181
+ }
182
+ }
183
+
184
+ export function renderAskPrompt({
185
+ add,
186
+ editor,
187
+ handoff,
188
+ isEditing,
189
+ keybindings,
190
+ choices,
191
+ prompt,
192
+ promptState,
193
+ state,
194
+ theme,
195
+ width,
196
+ }: RenderPromptOptions): void {
197
+ if (handoff) {
198
+ add(theme.fg("accent", " Human action needed"));
199
+ add();
200
+ }
201
+ addWrapped(add, theme.fg("text", prompt.title), width, " ");
202
+ add(
203
+ theme.fg(
204
+ "muted",
205
+ ` ${prompt.multiple ? "Choose any that apply" : "Choose one"}`,
206
+ ),
207
+ );
208
+ if (prompt.body) {
209
+ add();
210
+ addWrapped(add, theme.fg("muted", prompt.body), width, " ");
211
+ }
212
+ add();
213
+ choices.forEach((choice, index) => {
214
+ renderAskChoice({
215
+ add,
216
+ checked: promptState?.selections.includes(choice.label),
217
+ choice,
218
+ selected: state.focus === index,
219
+ theme,
220
+ width,
221
+ });
222
+ });
223
+ const selectedOther = state.focus === choices.length;
224
+ add(
225
+ `${selectedOther ? theme.fg("accent", "> ") : " "}${promptState?.customEnabled ? theme.fg("success", "✓ ") : " "}${theme.fg(selectedOther ? "accent" : "muted", OTHER_OPTION_LABEL)}${promptState?.customText.trim() ? theme.fg("text", `: ${promptState.customText.trim()}`) : ""}`,
226
+ );
227
+ if (isEditing === "other") {
228
+ add();
229
+ for (const line of editor.render(width - 2)) add(` ${line}`);
230
+ }
231
+ const selectedComment = state.focus === choices.length + 1;
232
+ add();
233
+ add(
234
+ `${selectedComment ? theme.fg("accent", "> ") : " "}${theme.fg(selectedComment ? "accent" : "text", "Comment (optional)")}`,
235
+ );
236
+ if (promptState?.comment.trim()) {
237
+ addWrapped(
238
+ add,
239
+ theme.fg("muted", promptState.comment.trim()),
240
+ width,
241
+ " ",
242
+ );
243
+ }
244
+ if (isEditing === "comment") {
245
+ add();
246
+ for (const line of editor.render(width - 2)) add(` ${line}`);
247
+ }
248
+ add();
249
+ if (isEditing) {
250
+ addWrapped(
251
+ add,
252
+ " " +
253
+ renderKeyHint(theme, keybindings, "tui.input.submit", "save") +
254
+ " • " +
255
+ renderKeyHint(theme, keybindings, "tui.input.tab", "next/default") +
256
+ " • " +
257
+ renderKeyHint(theme, keybindings, "tui.select.cancel", "cancel edit"),
258
+ width,
259
+ );
260
+ return;
261
+ }
262
+ addWrapped(
263
+ add,
264
+ " " +
265
+ renderKeyHint(theme, keybindings, "tui.select.up", "up") +
266
+ " • " +
267
+ renderKeyHint(theme, keybindings, "tui.select.down", "down") +
268
+ " • " +
269
+ renderKeyHint(theme, keybindings, "tui.select.confirm", "choose/type") +
270
+ " • " +
271
+ renderKeyHint(theme, keybindings, "tui.input.tab", "next/default") +
272
+ " • blank Other/rephrase = follow-up • " +
273
+ renderKeyHint(theme, keybindings, "tui.select.cancel", "close"),
274
+ width,
275
+ );
276
+ }