@geoqiao/pi-ask 1.1.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/CHANGELOG.md +194 -0
- package/LICENSE +22 -0
- package/README.md +282 -0
- package/docs/README.md +33 -0
- package/docs/configuration.md +406 -0
- package/docs/contract.md +309 -0
- package/docs/remote-events.md +187 -0
- package/package.json +130 -0
- package/skills/ask-user/SKILL.md +110 -0
- package/src/answer-commands.ts +361 -0
- package/src/answer-extraction.ts +354 -0
- package/src/ask-payload-store.ts +86 -0
- package/src/ask-settings-command.ts +14 -0
- package/src/ask-tool-helpers.ts +172 -0
- package/src/ask-tool.ts +84 -0
- package/src/config/defaults.ts +216 -0
- package/src/config/migrate.ts +70 -0
- package/src/config/migrations/index.ts +139 -0
- package/src/config/migrations/types.ts +10 -0
- package/src/config/schema.ts +287 -0
- package/src/config/store.ts +227 -0
- package/src/constants/keymaps.ts +721 -0
- package/src/constants/text.ts +12 -0
- package/src/constants/ui.ts +22 -0
- package/src/index.ts +30 -0
- package/src/math.ts +3 -0
- package/src/notifications.ts +119 -0
- package/src/remote-ask.ts +563 -0
- package/src/result-format.ts +157 -0
- package/src/result.ts +23 -0
- package/src/schema.ts +74 -0
- package/src/state/answers.ts +251 -0
- package/src/state/create.ts +18 -0
- package/src/state/editor.ts +70 -0
- package/src/state/navigation.ts +86 -0
- package/src/state/normalize.ts +326 -0
- package/src/state/question-type.ts +128 -0
- package/src/state/result.ts +263 -0
- package/src/state/selectors.ts +135 -0
- package/src/state/transitions.ts +330 -0
- package/src/state/view.ts +28 -0
- package/src/text.ts +98 -0
- package/src/types.ts +169 -0
- package/src/ui/auto-submit.ts +36 -0
- package/src/ui/autocomplete.ts +52 -0
- package/src/ui/controller.ts +645 -0
- package/src/ui/dismiss-guard.ts +26 -0
- package/src/ui/input.ts +160 -0
- package/src/ui/render-frame.ts +235 -0
- package/src/ui/render-helpers.ts +385 -0
- package/src/ui/render-question.ts +288 -0
- package/src/ui/render-submit.ts +168 -0
- package/src/ui/render-types.ts +33 -0
- package/src/ui/render.ts +53 -0
- package/src/ui/review-shortcuts.ts +43 -0
- package/src/ui/settings-list.ts +461 -0
- package/src/ui/show-settings.ts +37 -0
- package/src/ui/view-models/question.ts +203 -0
- package/src/ui/view-models/review.ts +100 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import {
|
|
2
|
+
truncateToWidth,
|
|
3
|
+
visibleWidth,
|
|
4
|
+
wrapTextWithAnsi,
|
|
5
|
+
} from "@earendil-works/pi-tui";
|
|
6
|
+
import { DEFAULT_ASK_CONFIG, normalizeAskConfig } from "../config/defaults.ts";
|
|
7
|
+
import type { AskConfig } from "../config/schema.ts";
|
|
8
|
+
import type { AskConfigNotice } from "../config/store.ts";
|
|
9
|
+
import {
|
|
10
|
+
getAskContextBindings,
|
|
11
|
+
matchesBinding,
|
|
12
|
+
renderSettingsFooterKeymaps,
|
|
13
|
+
} from "../constants/keymaps.ts";
|
|
14
|
+
|
|
15
|
+
interface Theme {
|
|
16
|
+
bg(color: string, text: string): string;
|
|
17
|
+
fg(color: string, text: string): string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface TuiLike {
|
|
21
|
+
requestRender(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface AskSettingsListOptions {
|
|
25
|
+
configPath: string;
|
|
26
|
+
notice?: AskConfigNotice;
|
|
27
|
+
onClose: () => void;
|
|
28
|
+
onSave: (config: AskConfig) => Promise<AskConfig>;
|
|
29
|
+
savedConfig: AskConfig;
|
|
30
|
+
tui: TuiLike;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const DESCRIPTION_LINE_COUNT = 3;
|
|
34
|
+
const COMPACT_WIDTH = 40;
|
|
35
|
+
const RESET_CONFIRMATION_MS = 2000;
|
|
36
|
+
|
|
37
|
+
type SettingSection = "Actions" | "Defaults for future asks" | "Live settings";
|
|
38
|
+
type ToggleSettingKey = keyof AskConfig["behaviour"] | "notifications.enabled";
|
|
39
|
+
|
|
40
|
+
interface BaseSetting {
|
|
41
|
+
description: string;
|
|
42
|
+
label: string;
|
|
43
|
+
section: SettingSection;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ActionSetting extends BaseSetting {
|
|
47
|
+
key: "resetConfig";
|
|
48
|
+
type: "action";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ToggleSetting extends BaseSetting {
|
|
52
|
+
key: ToggleSettingKey;
|
|
53
|
+
type: "toggle";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type Setting = ActionSetting | ToggleSetting;
|
|
57
|
+
|
|
58
|
+
const SETTINGS = [
|
|
59
|
+
{
|
|
60
|
+
description:
|
|
61
|
+
"Auto-submit completed ask flows when no question or option notes were added.",
|
|
62
|
+
key: "autoSubmitWhenAnsweredWithoutNotes",
|
|
63
|
+
section: "Live settings",
|
|
64
|
+
label: "Auto-submit when answered without notes",
|
|
65
|
+
type: "toggle",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
description:
|
|
69
|
+
"Require a second cancel or dismiss action before discarding answered or drafted ask content.",
|
|
70
|
+
key: "confirmDismissWhenDirty",
|
|
71
|
+
section: "Live settings",
|
|
72
|
+
label: "Confirm dismiss when dirty",
|
|
73
|
+
type: "toggle",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
description:
|
|
77
|
+
"Require pressing 1, 2, or 3 twice on the review tab before triggering Submit, Elaborate, or Cancel.",
|
|
78
|
+
key: "doublePressReviewShortcuts",
|
|
79
|
+
section: "Live settings",
|
|
80
|
+
label: "Double-press review shortcuts",
|
|
81
|
+
type: "toggle",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
description:
|
|
85
|
+
"Emit one external notification when the ask flow opens and waits for input.",
|
|
86
|
+
key: "notifications.enabled",
|
|
87
|
+
section: "Live settings",
|
|
88
|
+
label: "Notifications",
|
|
89
|
+
type: "toggle",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
description: "Show footer keymap hints at the bottom of the ask flow.",
|
|
93
|
+
key: "showFooterHints",
|
|
94
|
+
section: "Live settings",
|
|
95
|
+
label: "Show footer hints",
|
|
96
|
+
type: "toggle",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
description:
|
|
100
|
+
"Default new and replayed ask flows to render single-select questions as multi-select. This is not hot-applied to the current flow; use the type hotkey there.",
|
|
101
|
+
key: "presentSingleAsMulti",
|
|
102
|
+
section: "Defaults for future asks",
|
|
103
|
+
label: "Present single-select as multi-select",
|
|
104
|
+
type: "toggle",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
description:
|
|
108
|
+
"Reset behaviour, keymaps, notifications, and extraction settings to defaults. Press twice quickly to confirm.",
|
|
109
|
+
key: "resetConfig",
|
|
110
|
+
section: "Actions",
|
|
111
|
+
label: "Reset config to defaults",
|
|
112
|
+
type: "action",
|
|
113
|
+
},
|
|
114
|
+
] as const satisfies readonly Setting[];
|
|
115
|
+
|
|
116
|
+
type SettingKey = ToggleSettingKey;
|
|
117
|
+
|
|
118
|
+
export class AskSettingsList {
|
|
119
|
+
private closed = false;
|
|
120
|
+
private config: AskConfig;
|
|
121
|
+
private error?: string;
|
|
122
|
+
private focusIndex = 0;
|
|
123
|
+
private resetConfirmUntil = 0;
|
|
124
|
+
private notice?: AskConfigNotice;
|
|
125
|
+
private readonly configPath: string;
|
|
126
|
+
private readonly onClose: () => void;
|
|
127
|
+
private readonly onSave: (config: AskConfig) => Promise<AskConfig>;
|
|
128
|
+
private readonly theme: Theme;
|
|
129
|
+
private readonly tui: TuiLike;
|
|
130
|
+
|
|
131
|
+
constructor(theme: Theme, options: AskSettingsListOptions) {
|
|
132
|
+
this.theme = theme;
|
|
133
|
+
this.config = options.savedConfig;
|
|
134
|
+
this.configPath = options.configPath;
|
|
135
|
+
this.notice = options.notice;
|
|
136
|
+
this.onClose = options.onClose;
|
|
137
|
+
this.onSave = options.onSave;
|
|
138
|
+
this.tui = options.tui;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
handleInput(data: string): void {
|
|
142
|
+
const bindings = getAskContextBindings(this.config, "settingsModal");
|
|
143
|
+
if (matchesBinding(data, bindings.close)) {
|
|
144
|
+
this.close();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (matchesBinding(data, bindings.previousOption)) {
|
|
148
|
+
this.moveFocus(-1);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (matchesBinding(data, bindings.nextOption)) {
|
|
152
|
+
this.moveFocus(1);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (matchesBinding(data, bindings.toggle)) {
|
|
156
|
+
this.activateSelectedSetting();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
render(width: number): string[] {
|
|
161
|
+
const innerWidth = Math.max(24, width - 2);
|
|
162
|
+
const lines = [
|
|
163
|
+
this.topBorder(innerWidth),
|
|
164
|
+
this.line(
|
|
165
|
+
center(this.theme.fg("accent", "@eko24ive/pi-ask"), innerWidth),
|
|
166
|
+
innerWidth
|
|
167
|
+
),
|
|
168
|
+
this.line("", innerWidth),
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
this.appendWrapped(
|
|
172
|
+
lines,
|
|
173
|
+
this.theme.fg(
|
|
174
|
+
"muted",
|
|
175
|
+
"Edit this config file to customize keymaps, notifications, and extraction settings:"
|
|
176
|
+
),
|
|
177
|
+
innerWidth
|
|
178
|
+
);
|
|
179
|
+
this.appendWrapped(
|
|
180
|
+
lines,
|
|
181
|
+
this.theme.fg("accent", this.configPath),
|
|
182
|
+
innerWidth,
|
|
183
|
+
{
|
|
184
|
+
center: true,
|
|
185
|
+
}
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
lines.push(this.line("", innerWidth));
|
|
189
|
+
let previousSection: string | undefined;
|
|
190
|
+
for (const [index, setting] of SETTINGS.entries()) {
|
|
191
|
+
if (setting.section !== previousSection) {
|
|
192
|
+
if (previousSection) {
|
|
193
|
+
lines.push(this.line("", innerWidth));
|
|
194
|
+
}
|
|
195
|
+
lines.push(
|
|
196
|
+
this.line(this.theme.fg("accent", ` ${setting.section}`), innerWidth)
|
|
197
|
+
);
|
|
198
|
+
previousSection = setting.section;
|
|
199
|
+
}
|
|
200
|
+
for (const settingLine of this.renderSetting(
|
|
201
|
+
setting,
|
|
202
|
+
index,
|
|
203
|
+
innerWidth
|
|
204
|
+
)) {
|
|
205
|
+
lines.push(this.line(settingLine, innerWidth));
|
|
206
|
+
}
|
|
207
|
+
if (innerWidth < COMPACT_WIDTH && index < SETTINGS.length - 1) {
|
|
208
|
+
lines.push(this.line("", innerWidth));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
this.appendSelectedDescription(lines, innerWidth);
|
|
213
|
+
this.appendNotice(lines, innerWidth);
|
|
214
|
+
|
|
215
|
+
lines.push(this.line("", innerWidth));
|
|
216
|
+
this.appendFooter(lines, innerWidth);
|
|
217
|
+
lines.push(this.bottomBorder(innerWidth));
|
|
218
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
invalidate(): void {
|
|
222
|
+
// State-driven component; nothing cached across theme changes.
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
dispose(): void {
|
|
226
|
+
this.close();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private appendFooter(lines: string[], innerWidth: number): void {
|
|
230
|
+
if (innerWidth < COMPACT_WIDTH) {
|
|
231
|
+
const footer = renderSettingsFooterKeymaps(this.config);
|
|
232
|
+
for (const line of wrapTextWithAnsi(footer, innerWidth - 2)) {
|
|
233
|
+
lines.push(this.line(this.theme.fg("dim", ` ${line}`), innerWidth));
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
this.appendWrapped(
|
|
238
|
+
lines,
|
|
239
|
+
this.theme.fg("dim", renderSettingsFooterKeymaps(this.config)),
|
|
240
|
+
innerWidth
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private appendSelectedDescription(lines: string[], innerWidth: number): void {
|
|
245
|
+
const selectedSetting = SETTINGS[this.focusIndex];
|
|
246
|
+
if (!selectedSetting) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
lines.push(this.line("", innerWidth));
|
|
250
|
+
const descriptionLines = wrapTextWithAnsi(
|
|
251
|
+
this.theme.fg("muted", selectedSetting.description),
|
|
252
|
+
innerWidth - 2
|
|
253
|
+
).slice(0, DESCRIPTION_LINE_COUNT);
|
|
254
|
+
for (const line of descriptionLines) {
|
|
255
|
+
lines.push(this.line(` ${line}`, innerWidth));
|
|
256
|
+
}
|
|
257
|
+
for (
|
|
258
|
+
let index = descriptionLines.length;
|
|
259
|
+
index < DESCRIPTION_LINE_COUNT;
|
|
260
|
+
index++
|
|
261
|
+
) {
|
|
262
|
+
lines.push(this.line("", innerWidth));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private appendNotice(lines: string[], innerWidth: number): void {
|
|
267
|
+
if (this.notice) {
|
|
268
|
+
lines.push(this.line("", innerWidth));
|
|
269
|
+
this.appendWrapped(
|
|
270
|
+
lines,
|
|
271
|
+
this.theme.fg(
|
|
272
|
+
this.notice.kind === "success" ? "accent" : "warning",
|
|
273
|
+
this.notice.text
|
|
274
|
+
),
|
|
275
|
+
innerWidth
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (this.error) {
|
|
279
|
+
lines.push(this.line("", innerWidth));
|
|
280
|
+
this.appendWrapped(lines, this.theme.fg("error", this.error), innerWidth);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private appendWrapped(
|
|
285
|
+
lines: string[],
|
|
286
|
+
content: string,
|
|
287
|
+
innerWidth: number,
|
|
288
|
+
options: { center?: boolean } = {}
|
|
289
|
+
): void {
|
|
290
|
+
for (const line of wrapTextWithAnsi(content, innerWidth - 2)) {
|
|
291
|
+
const contentLine = options.center
|
|
292
|
+
? center(line, innerWidth)
|
|
293
|
+
: ` ${line}`;
|
|
294
|
+
lines.push(this.line(contentLine, innerWidth));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private renderSetting(
|
|
299
|
+
setting: Setting,
|
|
300
|
+
index: number,
|
|
301
|
+
innerWidth: number
|
|
302
|
+
): string[] {
|
|
303
|
+
const selected = index === this.focusIndex;
|
|
304
|
+
const prefix = selected ? this.theme.fg("accent", "❯ ") : " ";
|
|
305
|
+
const continuationPrefix = " ";
|
|
306
|
+
if (setting.type === "action") {
|
|
307
|
+
return [center(this.renderSettingValue(setting, selected), innerWidth)];
|
|
308
|
+
}
|
|
309
|
+
const value = this.renderSettingValue(setting, selected);
|
|
310
|
+
const valueWidth = visibleWidth(value);
|
|
311
|
+
const labelWidth = Math.max(
|
|
312
|
+
1,
|
|
313
|
+
innerWidth - visibleWidth(prefix) - valueWidth - 2
|
|
314
|
+
);
|
|
315
|
+
const labelLines = wrapTextWithAnsi(setting.label, labelWidth);
|
|
316
|
+
const firstLabelLine = labelLines[0] ?? "";
|
|
317
|
+
const gap = " ".repeat(
|
|
318
|
+
Math.max(1, labelWidth - visibleWidth(firstLabelLine) + 1)
|
|
319
|
+
);
|
|
320
|
+
const firstLine = `${prefix}${firstLabelLine}${gap}${value}`;
|
|
321
|
+
const continuationLines = labelLines
|
|
322
|
+
.slice(1)
|
|
323
|
+
.map((line) => `${continuationPrefix}${line}`);
|
|
324
|
+
return [firstLine, ...continuationLines];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private renderSettingValue(setting: Setting, selected: boolean): string {
|
|
328
|
+
if (setting.type === "action") {
|
|
329
|
+
return this.renderValue(
|
|
330
|
+
this.isResetConfirmationActive() ? "confirm reset" : "reset all",
|
|
331
|
+
selected
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return this.renderValue(this.getSettingValue(setting.key), selected);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private renderValue(value: boolean | string, selected: boolean): string {
|
|
338
|
+
const valueText = formatSettingValue(value);
|
|
339
|
+
const styledValue = selected
|
|
340
|
+
? this.theme.bg("selectedBg", this.theme.fg("accent", valueText))
|
|
341
|
+
: this.theme.fg("muted", valueText);
|
|
342
|
+
return `${this.theme.fg("dim", "[")}${styledValue}${this.theme.fg("dim", "]")}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private getSettingValue(key: SettingKey): boolean {
|
|
346
|
+
return key === "notifications.enabled"
|
|
347
|
+
? this.config.notifications.enabled
|
|
348
|
+
: this.config.behaviour[key];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private moveFocus(delta: 1 | -1): void {
|
|
352
|
+
this.resetConfirmUntil = 0;
|
|
353
|
+
this.focusIndex =
|
|
354
|
+
(this.focusIndex + delta + SETTINGS.length) % SETTINGS.length;
|
|
355
|
+
this.tui.requestRender();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private activateSelectedSetting(): void {
|
|
359
|
+
const setting = SETTINGS[this.focusIndex];
|
|
360
|
+
if (!setting) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (setting.type === "action") {
|
|
364
|
+
this.resetConfigWithConfirmation();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.resetConfirmUntil = 0;
|
|
368
|
+
this.saveSetting(setting.key, !this.getSettingValue(setting.key));
|
|
369
|
+
this.tui.requestRender();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private resetConfigWithConfirmation(): void {
|
|
373
|
+
if (!this.isResetConfirmationActive()) {
|
|
374
|
+
this.resetConfirmUntil = Date.now() + RESET_CONFIRMATION_MS;
|
|
375
|
+
this.tui.requestRender();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
this.resetConfirmUntil = 0;
|
|
379
|
+
this.saveConfig(normalizeAskConfig(DEFAULT_ASK_CONFIG));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private isResetConfirmationActive(): boolean {
|
|
383
|
+
return Date.now() <= this.resetConfirmUntil;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private saveSetting(key: SettingKey, enabled: boolean): void {
|
|
387
|
+
const nextConfig =
|
|
388
|
+
key === "notifications.enabled"
|
|
389
|
+
? {
|
|
390
|
+
...this.config,
|
|
391
|
+
notifications: {
|
|
392
|
+
...this.config.notifications,
|
|
393
|
+
enabled,
|
|
394
|
+
},
|
|
395
|
+
}
|
|
396
|
+
: {
|
|
397
|
+
...this.config,
|
|
398
|
+
behaviour: {
|
|
399
|
+
...this.config.behaviour,
|
|
400
|
+
[key]: enabled,
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
this.saveConfig(nextConfig);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private saveConfig(nextConfig: AskConfig): void {
|
|
407
|
+
this.error = undefined;
|
|
408
|
+
const previousConfig = this.config;
|
|
409
|
+
this.config = nextConfig;
|
|
410
|
+
this.onSave(nextConfig)
|
|
411
|
+
.then((savedConfig) => {
|
|
412
|
+
this.config = savedConfig;
|
|
413
|
+
this.notice = undefined;
|
|
414
|
+
this.tui.requestRender();
|
|
415
|
+
})
|
|
416
|
+
.catch((error: unknown) => {
|
|
417
|
+
this.config = previousConfig;
|
|
418
|
+
this.error = error instanceof Error ? error.message : String(error);
|
|
419
|
+
this.tui.requestRender();
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private topBorder(innerWidth: number): string {
|
|
424
|
+
return this.theme.fg("muted", `╭${"─".repeat(innerWidth)}╮`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private bottomBorder(innerWidth: number): string {
|
|
428
|
+
return this.theme.fg("muted", `╰${"─".repeat(innerWidth)}╯`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private line(content: string, innerWidth: number): string {
|
|
432
|
+
const clipped = truncateToWidth(content, innerWidth, "…");
|
|
433
|
+
return `${this.theme.fg("muted", "│")}${padToWidth(clipped, innerWidth)}${this.theme.fg("muted", "│")}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private close(): void {
|
|
437
|
+
if (this.closed) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
this.closed = true;
|
|
441
|
+
this.onClose();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function formatSettingValue(value: boolean | string): string {
|
|
446
|
+
if (typeof value === "string") {
|
|
447
|
+
return value;
|
|
448
|
+
}
|
|
449
|
+
return value ? "on" : "off";
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function center(text: string, width: number): string {
|
|
453
|
+
const padding = Math.max(0, width - visibleWidth(text));
|
|
454
|
+
const left = Math.floor(padding / 2);
|
|
455
|
+
const right = padding - left;
|
|
456
|
+
return `${" ".repeat(left)}${text}${" ".repeat(right)}`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function padToWidth(text: string, width: number): string {
|
|
460
|
+
return `${text}${" ".repeat(Math.max(0, width - visibleWidth(text)))}`;
|
|
461
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getAskConfigPath, getAskConfigStore } from "../config/store.ts";
|
|
3
|
+
import { AskSettingsList } from "./settings-list.ts";
|
|
4
|
+
|
|
5
|
+
export async function showAskSettings(
|
|
6
|
+
ctx: Pick<ExtensionContext, "mode" | "ui">
|
|
7
|
+
): Promise<void> {
|
|
8
|
+
if (ctx.mode !== "tui") {
|
|
9
|
+
ctx.ui.notify("/ask-settings requires interactive TUI mode.", "error");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const store = getAskConfigStore();
|
|
13
|
+
const { config, notice } = await store.ensureLoaded();
|
|
14
|
+
return ctx.ui.custom<void>(
|
|
15
|
+
(tui, theme, _keybindings, done) =>
|
|
16
|
+
new AskSettingsList(theme, {
|
|
17
|
+
configPath: getAskConfigPath(),
|
|
18
|
+
notice,
|
|
19
|
+
onClose: () => {
|
|
20
|
+
done();
|
|
21
|
+
},
|
|
22
|
+
onSave: async (nextConfig) => store.save(nextConfig),
|
|
23
|
+
savedConfig: config,
|
|
24
|
+
tui,
|
|
25
|
+
}),
|
|
26
|
+
{
|
|
27
|
+
overlay: true,
|
|
28
|
+
overlayOptions: {
|
|
29
|
+
anchor: "center",
|
|
30
|
+
margin: 1,
|
|
31
|
+
maxHeight: "90%",
|
|
32
|
+
minWidth: 26,
|
|
33
|
+
width: 72,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { UI_DIMENSIONS, UI_TEXT } from "../../constants/ui.ts";
|
|
2
|
+
import { isOptionSelected } from "../../state/answers.ts";
|
|
3
|
+
import {
|
|
4
|
+
getAnswer,
|
|
5
|
+
getOptionNote,
|
|
6
|
+
getQuestionNote,
|
|
7
|
+
isInputOpenForQuestion,
|
|
8
|
+
isOptionNoteOpen,
|
|
9
|
+
isQuestionNoteOpen,
|
|
10
|
+
} from "../../state/selectors.ts";
|
|
11
|
+
import type { AskDisplayOption } from "../../types.ts";
|
|
12
|
+
import type { QuestionRenderContext } from "../render-types.ts";
|
|
13
|
+
|
|
14
|
+
export type QuestionNoteModel =
|
|
15
|
+
| { kind: "editor"; placeholder: string }
|
|
16
|
+
| { kind: "saved"; text: string };
|
|
17
|
+
|
|
18
|
+
export type OptionDetailModel =
|
|
19
|
+
| { kind: "editor"; placeholder: string; withGap: boolean }
|
|
20
|
+
| { kind: "saved-note"; text: string; withGap: boolean }
|
|
21
|
+
| { kind: "custom-text"; text: string; withGap: boolean };
|
|
22
|
+
|
|
23
|
+
export interface OptionRowModel {
|
|
24
|
+
color: "accent" | "text" | "success";
|
|
25
|
+
description?: string;
|
|
26
|
+
detail?: OptionDetailModel;
|
|
27
|
+
index: number;
|
|
28
|
+
isCustom: boolean;
|
|
29
|
+
isFreeformOnly: boolean;
|
|
30
|
+
label: string;
|
|
31
|
+
pointer: string;
|
|
32
|
+
prefix: string;
|
|
33
|
+
selected: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface QuestionScreenModel {
|
|
37
|
+
mode: "preview" | "standard";
|
|
38
|
+
previewLayout?: "custom" | "stacked" | "wide";
|
|
39
|
+
questionNote?: QuestionNoteModel;
|
|
40
|
+
rows: OptionRowModel[];
|
|
41
|
+
selectedOption?: AskDisplayOption;
|
|
42
|
+
selectedOptionDetail?: OptionDetailModel;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildQuestionScreenModel(
|
|
46
|
+
context: QuestionRenderContext
|
|
47
|
+
): QuestionScreenModel {
|
|
48
|
+
const rows = context.options.map((option, index) =>
|
|
49
|
+
buildOptionRowModel(context, option, index)
|
|
50
|
+
);
|
|
51
|
+
const questionNote = buildQuestionNoteModel(context);
|
|
52
|
+
if (context.question.type !== "preview") {
|
|
53
|
+
return {
|
|
54
|
+
mode: "standard",
|
|
55
|
+
questionNote,
|
|
56
|
+
rows,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const selectedOption = context.options[context.state.activeOptionIndex];
|
|
61
|
+
return {
|
|
62
|
+
mode: "preview",
|
|
63
|
+
previewLayout: getPreviewLayout(context, selectedOption),
|
|
64
|
+
questionNote,
|
|
65
|
+
rows,
|
|
66
|
+
selectedOption,
|
|
67
|
+
selectedOptionDetail: buildOptionDetailModel(context, selectedOption, true),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildQuestionNoteModel(
|
|
72
|
+
context: QuestionRenderContext
|
|
73
|
+
): QuestionNoteModel | undefined {
|
|
74
|
+
const { state, question } = context;
|
|
75
|
+
if (isQuestionNoteOpen(state, question.id)) {
|
|
76
|
+
return { kind: "editor", placeholder: UI_TEXT.editorPlaceholderNote };
|
|
77
|
+
}
|
|
78
|
+
const note = getQuestionNote(state, question.id);
|
|
79
|
+
return note ? { kind: "saved", text: note } : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildOptionRowModel(
|
|
83
|
+
context: QuestionRenderContext,
|
|
84
|
+
option: AskDisplayOption,
|
|
85
|
+
index: number
|
|
86
|
+
): OptionRowModel {
|
|
87
|
+
const { question, state } = context;
|
|
88
|
+
const answer = getAnswer(state, question.id);
|
|
89
|
+
const selected = index === state.activeOptionIndex;
|
|
90
|
+
const answered = option.isCustomOption
|
|
91
|
+
? !!(answer?.customSelected && answer.customText?.trim())
|
|
92
|
+
: isOptionSelected(answer, option.value);
|
|
93
|
+
const pointer = getOptionPointer(option, selected);
|
|
94
|
+
return {
|
|
95
|
+
color: getOptionColor(answered, selected),
|
|
96
|
+
description: option.description,
|
|
97
|
+
detail:
|
|
98
|
+
context.question.type === "preview"
|
|
99
|
+
? undefined
|
|
100
|
+
: buildOptionDetailModel(context, option, selected),
|
|
101
|
+
index,
|
|
102
|
+
isCustom: !!option.isCustomOption,
|
|
103
|
+
isFreeformOnly: !!option.isFreeformOnlyOption,
|
|
104
|
+
label: option.label,
|
|
105
|
+
pointer,
|
|
106
|
+
prefix: getOptionPrefix(question.type, option, answered),
|
|
107
|
+
selected,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getOptionPointer(option: AskDisplayOption, selected: boolean): string {
|
|
112
|
+
if (option.isFreeformOnlyOption) {
|
|
113
|
+
return "";
|
|
114
|
+
}
|
|
115
|
+
return selected ? "❯ " : " ";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function buildOptionDetailModel(
|
|
119
|
+
context: QuestionRenderContext,
|
|
120
|
+
option: AskDisplayOption | undefined,
|
|
121
|
+
selected: boolean
|
|
122
|
+
): OptionDetailModel | undefined {
|
|
123
|
+
if (!option) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { state, question } = context;
|
|
128
|
+
const answer = getAnswer(state, question.id);
|
|
129
|
+
const inputOpen =
|
|
130
|
+
!!option.isCustomOption &&
|
|
131
|
+
selected &&
|
|
132
|
+
isInputOpenForQuestion(state, question.id);
|
|
133
|
+
const noteOpen =
|
|
134
|
+
!option.isCustomOption &&
|
|
135
|
+
isOptionNoteOpen(state, question.id, option.value);
|
|
136
|
+
const customText = option.isCustomOption ? answer?.customText : undefined;
|
|
137
|
+
const note = option.isCustomOption
|
|
138
|
+
? undefined
|
|
139
|
+
: getOptionNote(state, question.id, option.value);
|
|
140
|
+
|
|
141
|
+
if (noteOpen) {
|
|
142
|
+
return {
|
|
143
|
+
kind: "editor",
|
|
144
|
+
placeholder: UI_TEXT.editorPlaceholderNote,
|
|
145
|
+
withGap: false,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (inputOpen) {
|
|
149
|
+
return {
|
|
150
|
+
kind: "editor",
|
|
151
|
+
placeholder: UI_TEXT.editorPlaceholderInput,
|
|
152
|
+
withGap: !!option.isFreeformOnlyOption,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (customText) {
|
|
156
|
+
return {
|
|
157
|
+
kind: "custom-text",
|
|
158
|
+
text: customText,
|
|
159
|
+
withGap: !!option.isFreeformOnlyOption,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (note) {
|
|
163
|
+
return { kind: "saved-note", text: note, withGap: false };
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getPreviewLayout(
|
|
169
|
+
context: QuestionRenderContext,
|
|
170
|
+
selectedOption: AskDisplayOption | undefined
|
|
171
|
+
): "custom" | "stacked" | "wide" {
|
|
172
|
+
if (selectedOption?.isCustomOption) {
|
|
173
|
+
return "custom";
|
|
174
|
+
}
|
|
175
|
+
return context.width >= UI_DIMENSIONS.previewWideMinWidth &&
|
|
176
|
+
context.options.length > 0
|
|
177
|
+
? "wide"
|
|
178
|
+
: "stacked";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function getOptionPrefix(
|
|
182
|
+
questionType: QuestionRenderContext["question"]["type"],
|
|
183
|
+
_option: AskDisplayOption,
|
|
184
|
+
answered: boolean
|
|
185
|
+
): string {
|
|
186
|
+
if (questionType !== "multi") {
|
|
187
|
+
return "";
|
|
188
|
+
}
|
|
189
|
+
return `[${answered ? "x" : " "}] `;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function getOptionColor(
|
|
193
|
+
answered: boolean,
|
|
194
|
+
selected: boolean
|
|
195
|
+
): "accent" | "text" | "success" {
|
|
196
|
+
if (answered) {
|
|
197
|
+
return "success";
|
|
198
|
+
}
|
|
199
|
+
if (selected) {
|
|
200
|
+
return "accent";
|
|
201
|
+
}
|
|
202
|
+
return "text";
|
|
203
|
+
}
|