@hheei/pi-ask-user 0.0.0-stage → 0.1.1
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/LICENSE +22 -0
- package/README.md +68 -2
- package/dist/ask-prompt.d.ts +60 -0
- package/dist/ask-prompt.js +360 -0
- package/dist/batch-prompt.d.ts +42 -0
- package/dist/batch-prompt.js +192 -0
- package/dist/constants.d.ts +15 -0
- package/dist/constants.js +17 -0
- package/dist/custom-prompt.d.ts +20 -0
- package/dist/custom-prompt.js +56 -0
- package/dist/dialogs.d.ts +25 -0
- package/dist/dialogs.js +93 -0
- package/dist/events.d.ts +23 -0
- package/dist/events.js +22 -0
- package/dist/execute.d.ts +5 -0
- package/dist/execute.js +157 -0
- package/dist/format.d.ts +18 -0
- package/dist/format.js +84 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/multi-select-list.d.ts +25 -0
- package/dist/multi-select-list.js +155 -0
- package/dist/option-rows.d.ts +33 -0
- package/dist/option-rows.js +89 -0
- package/dist/preferences.d.ts +8 -0
- package/dist/preferences.js +25 -0
- package/dist/presentation.d.ts +6 -0
- package/dist/presentation.js +129 -0
- package/dist/prompt-context.d.ts +26 -0
- package/dist/prompt-context.js +68 -0
- package/dist/prompt-help.d.ts +23 -0
- package/dist/prompt-help.js +50 -0
- package/dist/prompt-layout.d.ts +43 -0
- package/dist/prompt-layout.js +89 -0
- package/dist/prompt-overflow.d.ts +5 -0
- package/dist/prompt-overflow.js +59 -0
- package/dist/response.d.ts +46 -0
- package/dist/response.js +31 -0
- package/dist/schema.d.ts +43 -0
- package/dist/schema.js +87 -0
- package/dist/settings-provider.d.ts +15 -0
- package/dist/settings-provider.js +42 -0
- package/dist/shortcuts.d.ts +7 -0
- package/dist/shortcuts.js +21 -0
- package/dist/single-select-layout.d.ts +19 -0
- package/dist/single-select-layout.js +61 -0
- package/dist/single-select-list.d.ts +23 -0
- package/dist/single-select-list.js +99 -0
- package/dist/tool.d.ts +2 -0
- package/dist/tool.js +91 -0
- package/package.json +56 -3
- package/skills/ask-user/SKILL.md +189 -0
- package/skills/ask-user/references/ask-user-skill-extension-spec.md +106 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { KeybindingsManager } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { AskMode } from "./constants";
|
|
4
|
+
import type { AskDisplayMode } from "./schema";
|
|
5
|
+
export declare function formatKeyList(keys: readonly string[]): string;
|
|
6
|
+
export interface HelpHintInput {
|
|
7
|
+
theme: Theme;
|
|
8
|
+
mode: AskMode;
|
|
9
|
+
displayMode: AskDisplayMode;
|
|
10
|
+
allowMultiple: boolean;
|
|
11
|
+
hasOptions: boolean;
|
|
12
|
+
contextCollapsible: boolean;
|
|
13
|
+
contextToggleKey: string;
|
|
14
|
+
/** True when the prompt pane clipped, so the scroll keys are worth showing. */
|
|
15
|
+
promptScrollable: boolean;
|
|
16
|
+
keybindings: KeybindingsManager;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* One dim hint row: the keys the current mode accepts, in the order they matter.
|
|
20
|
+
* Every hint that does not apply drops out, so the row never wraps on an 80-column
|
|
21
|
+
* terminal unless a remapped key makes it long.
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildHelpText(input: HelpHintInput): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Keys that render as one glyph so the whole hint row still fits a terminal row. */
|
|
2
|
+
const KEY_GLYPH = {
|
|
3
|
+
up: "↑",
|
|
4
|
+
down: "↓",
|
|
5
|
+
enter: "↵",
|
|
6
|
+
escape: "⎋",
|
|
7
|
+
space: "␣",
|
|
8
|
+
tab: "⇥",
|
|
9
|
+
};
|
|
10
|
+
export function formatKeyList(keys) {
|
|
11
|
+
return keys.map((key) => KEY_GLYPH[key] ?? key).join("/");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* One dim hint row: the keys the current mode accepts, in the order they matter.
|
|
15
|
+
* Every hint that does not apply drops out, so the row never wraps on an 80-column
|
|
16
|
+
* terminal unless a remapped key makes it long.
|
|
17
|
+
*/
|
|
18
|
+
export function buildHelpText(input) {
|
|
19
|
+
const { theme, keybindings } = input;
|
|
20
|
+
const binding = (id) => formatKeyList(keybindings.getKeys(id));
|
|
21
|
+
const alternateCancelKeys = keybindings
|
|
22
|
+
.getKeys("tui.select.cancel")
|
|
23
|
+
.filter((key) => key !== "escape");
|
|
24
|
+
const alternateCancel = alternateCancelKeys.length > 0 ? [formatKeyList(alternateCancelKeys), "cancel"] : null;
|
|
25
|
+
const hints = input.mode === "select"
|
|
26
|
+
? [
|
|
27
|
+
["↕", "navigate"],
|
|
28
|
+
input.allowMultiple
|
|
29
|
+
? [binding("tui.select.confirm"), "submit"]
|
|
30
|
+
: [binding("tui.select.confirm"), "select"],
|
|
31
|
+
input.allowMultiple ? ["␣", "toggle"] : null,
|
|
32
|
+
input.contextCollapsible ? [input.contextToggleKey, "context"] : null,
|
|
33
|
+
input.promptScrollable ? ["PgUp/PgDn", "prompt"] : null,
|
|
34
|
+
[binding("tui.select.cancel"), "cancel"],
|
|
35
|
+
]
|
|
36
|
+
: [
|
|
37
|
+
[binding("tui.input.submit"), "submit"],
|
|
38
|
+
[binding("tui.input.newLine"), "newline"],
|
|
39
|
+
[
|
|
40
|
+
input.hasOptions ? "⎋" : binding("tui.select.cancel"),
|
|
41
|
+
input.hasOptions ? "back" : "cancel",
|
|
42
|
+
],
|
|
43
|
+
// esc only steps back to the list, so the cancel key still needs a hint.
|
|
44
|
+
alternateCancel,
|
|
45
|
+
];
|
|
46
|
+
return theme.fg("dim", hints
|
|
47
|
+
.filter((hint) => hint !== null)
|
|
48
|
+
.map(([keys, label]) => `${keys} ${label}`)
|
|
49
|
+
.join(" · "));
|
|
50
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type AskMode } from "./constants";
|
|
3
|
+
import type { AskDisplayMode } from "./schema";
|
|
4
|
+
/** A surface is bracketed by two `──` rails, the way `pi-ext-ui` groups are. */
|
|
5
|
+
export declare const RAIL_ROWS = 2;
|
|
6
|
+
/** The native separator row: one full-width `─` rule in `borderMuted`. */
|
|
7
|
+
export declare function railRow(theme: Theme, width: number): string;
|
|
8
|
+
/** Rows an overlay may draw for the terminal height: a ratio cap with a usable floor. */
|
|
9
|
+
export declare function getOverlayMaxRenderLinesForRows(rows: number): number;
|
|
10
|
+
export declare function modeMinRows(mode: AskMode): number;
|
|
11
|
+
export declare function modePreferredRows(mode: AskMode): number;
|
|
12
|
+
export declare function overlayHelpBudget(bodyCapacity: number, renderedHelpRows: number): number;
|
|
13
|
+
export interface ContextCollapseInput {
|
|
14
|
+
displayMode: AskDisplayMode;
|
|
15
|
+
mode: AskMode;
|
|
16
|
+
contextCollapsible: boolean;
|
|
17
|
+
questionRows: number;
|
|
18
|
+
contextRows: number;
|
|
19
|
+
bodyCapacity: number;
|
|
20
|
+
helpRows: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Inline mode always follows the row budget; the overlay keeps context open
|
|
24
|
+
* when the prompt still fits next to the minimum mode rows.
|
|
25
|
+
*/
|
|
26
|
+
export declare function contextShouldCollapse(input: ContextCollapseInput): boolean;
|
|
27
|
+
export interface BodyPlanInput {
|
|
28
|
+
maxRenderLines: number;
|
|
29
|
+
mode: AskMode;
|
|
30
|
+
contextCollapsible: boolean;
|
|
31
|
+
promptRows: number;
|
|
32
|
+
helpRows: number;
|
|
33
|
+
}
|
|
34
|
+
export interface BodyPlan {
|
|
35
|
+
bodyCapacity: number;
|
|
36
|
+
helpBudget: number;
|
|
37
|
+
contentRows: number;
|
|
38
|
+
promptBudget: number;
|
|
39
|
+
modeBudget: number;
|
|
40
|
+
separatorRows: number;
|
|
41
|
+
}
|
|
42
|
+
/** Split the overlay rows between prompt, mode body and help. */
|
|
43
|
+
export declare function planOverlayBody(input: BodyPlanInput): BodyPlan;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { ASK_OVERLAY_MAX_HEIGHT_RATIO, ASK_OVERLAY_MIN_RENDER_LINES, } from "./constants";
|
|
2
|
+
/** Rows each mode needs to stay usable, and the size it grows to when there is room. */
|
|
3
|
+
const MODE_ROWS = {
|
|
4
|
+
select: { min: 3, preferred: 8 },
|
|
5
|
+
freeform: { min: 5, preferred: 10 },
|
|
6
|
+
};
|
|
7
|
+
/** A surface is bracketed by two `──` rails, the way `pi-ext-ui` groups are. */
|
|
8
|
+
export const RAIL_ROWS = 2;
|
|
9
|
+
/** The native separator row: one full-width `─` rule in `borderMuted`. */
|
|
10
|
+
export function railRow(theme, width) {
|
|
11
|
+
return theme.fg("borderMuted", "─".repeat(Math.max(0, width)));
|
|
12
|
+
}
|
|
13
|
+
/** Rows an overlay may draw for the terminal height: a ratio cap with a usable floor. */
|
|
14
|
+
export function getOverlayMaxRenderLinesForRows(rows) {
|
|
15
|
+
const normalizedRows = Number.isFinite(rows) ? Math.max(1, Math.floor(rows)) : 24;
|
|
16
|
+
const availableRows = Math.max(1, normalizedRows - 2);
|
|
17
|
+
const ratioRows = Math.max(1, Math.floor(normalizedRows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
|
|
18
|
+
const minimumRows = Math.min(ASK_OVERLAY_MIN_RENDER_LINES, availableRows);
|
|
19
|
+
return Math.min(availableRows, Math.max(minimumRows, ratioRows));
|
|
20
|
+
}
|
|
21
|
+
export function modeMinRows(mode) {
|
|
22
|
+
return MODE_ROWS[mode].min;
|
|
23
|
+
}
|
|
24
|
+
export function modePreferredRows(mode) {
|
|
25
|
+
return MODE_ROWS[mode].preferred;
|
|
26
|
+
}
|
|
27
|
+
export function overlayHelpBudget(bodyCapacity, renderedHelpRows) {
|
|
28
|
+
if (renderedHelpRows <= 0 || bodyCapacity <= 0)
|
|
29
|
+
return 0;
|
|
30
|
+
if (bodyCapacity >= 12)
|
|
31
|
+
return Math.min(2, renderedHelpRows);
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Inline mode always follows the row budget; the overlay keeps context open
|
|
36
|
+
* when the prompt still fits next to the minimum mode rows.
|
|
37
|
+
*/
|
|
38
|
+
export function contextShouldCollapse(input) {
|
|
39
|
+
if (input.displayMode === "inline")
|
|
40
|
+
return input.contextCollapsible;
|
|
41
|
+
if (input.mode !== "select")
|
|
42
|
+
return input.contextCollapsible;
|
|
43
|
+
if (input.contextRows === 0)
|
|
44
|
+
return false;
|
|
45
|
+
const helpBudget = overlayHelpBudget(input.bodyCapacity, input.helpRows);
|
|
46
|
+
const contentRows = Math.max(0, input.bodyCapacity - helpBudget);
|
|
47
|
+
const separatorRows = contentRows >= 4 ? 1 : 0;
|
|
48
|
+
const promptCapacity = Math.max(0, contentRows - separatorRows - modeMinRows(input.mode));
|
|
49
|
+
return input.questionRows + 1 + input.contextRows > promptCapacity;
|
|
50
|
+
}
|
|
51
|
+
/** Split the overlay rows between prompt, mode body and help. */
|
|
52
|
+
export function planOverlayBody(input) {
|
|
53
|
+
const bodyCapacity = Math.max(0, input.maxRenderLines);
|
|
54
|
+
const helpBudget = overlayHelpBudget(bodyCapacity, input.helpRows);
|
|
55
|
+
const contentRows = Math.max(0, bodyCapacity - helpBudget);
|
|
56
|
+
let promptBudget = 0;
|
|
57
|
+
let modeBudget = 0;
|
|
58
|
+
let separatorRows = 0;
|
|
59
|
+
if (input.mode === "select") {
|
|
60
|
+
separatorRows = contentRows >= 4 ? 1 : 0;
|
|
61
|
+
const promptAndModeRows = Math.max(0, contentRows - separatorRows);
|
|
62
|
+
promptBudget = promptAndModeRows;
|
|
63
|
+
if (promptAndModeRows > 0) {
|
|
64
|
+
const promptMinRows = input.promptRows > 0 ? 1 : 0;
|
|
65
|
+
const maximumModeRows = Math.max(0, promptAndModeRows - promptMinRows);
|
|
66
|
+
const modeMin = Math.min(modeMinRows(input.mode), maximumModeRows);
|
|
67
|
+
modeBudget = Math.min(modePreferredRows(input.mode), maximumModeRows);
|
|
68
|
+
modeBudget = Math.max(modeMin, modeBudget);
|
|
69
|
+
promptBudget = promptAndModeRows - modeBudget;
|
|
70
|
+
const usefulPromptTarget = input.contextCollapsible ? 3 : 2;
|
|
71
|
+
const usefulPromptRows = Math.min(input.promptRows, promptAndModeRows >= modeMin + usefulPromptTarget ? usefulPromptTarget : promptMinRows);
|
|
72
|
+
if (promptBudget < usefulPromptRows && modeBudget > modeMin) {
|
|
73
|
+
const shiftedRows = Math.min(usefulPromptRows - promptBudget, modeBudget - modeMin);
|
|
74
|
+
modeBudget -= shiftedRows;
|
|
75
|
+
promptBudget += shiftedRows;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
modeBudget = Math.min(modePreferredRows(input.mode), contentRows);
|
|
81
|
+
modeBudget = Math.max(Math.min(modeMinRows(input.mode), contentRows), modeBudget);
|
|
82
|
+
promptBudget = Math.max(0, contentRows - modeBudget);
|
|
83
|
+
if (promptBudget > 0 && modeBudget > 0) {
|
|
84
|
+
separatorRows = 1;
|
|
85
|
+
promptBudget = Math.max(0, promptBudget - separatorRows);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { bodyCapacity, helpBudget, contentRows, promptBudget, modeBudget, separatorRows };
|
|
89
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
export declare function applyOverflowMarkers(theme: Theme, lines: string[], width: number, hasHiddenAbove: boolean, hasHiddenBelow: boolean): string[];
|
|
3
|
+
export declare function limitLines(theme: Theme, lines: string[], budget: number, width: number, showOverflowMarker: boolean): string[];
|
|
4
|
+
/** Editor rows keep their top/bottom borders and follow the cursor when clipped. */
|
|
5
|
+
export declare function limitEditorLines(theme: Theme, lines: string[], budget: number, width: number): string[];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { CURSOR_MARKER, truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
export function applyOverflowMarkers(theme, lines, width, hasHiddenAbove, hasHiddenBelow) {
|
|
3
|
+
if (lines.length === 0)
|
|
4
|
+
return lines;
|
|
5
|
+
const marker = (line, glyph) => truncateToWidth(`${theme.fg("dim", glyph)} ${line}`, width, "", true);
|
|
6
|
+
const marked = [...lines];
|
|
7
|
+
if (hasHiddenAbove && hasHiddenBelow && marked.length === 1) {
|
|
8
|
+
marked[0] = marker(marked[0] ?? "", "↕");
|
|
9
|
+
return marked;
|
|
10
|
+
}
|
|
11
|
+
if (hasHiddenAbove) {
|
|
12
|
+
marked[0] = marker(marked[0] ?? "", "↑");
|
|
13
|
+
}
|
|
14
|
+
if (hasHiddenBelow) {
|
|
15
|
+
const lastIndex = marked.length - 1;
|
|
16
|
+
marked[lastIndex] = marker(marked[lastIndex] ?? "", "↓");
|
|
17
|
+
}
|
|
18
|
+
return marked;
|
|
19
|
+
}
|
|
20
|
+
const truncateLines = (lines, width) => lines.map((line) => truncateToWidth(line, width, "", true));
|
|
21
|
+
export function limitLines(theme, lines, budget, width, showOverflowMarker) {
|
|
22
|
+
const safeBudget = Math.max(0, Math.floor(budget));
|
|
23
|
+
if (safeBudget <= 0)
|
|
24
|
+
return [];
|
|
25
|
+
if (lines.length <= safeBudget)
|
|
26
|
+
return truncateLines(lines, width);
|
|
27
|
+
if (!showOverflowMarker)
|
|
28
|
+
return truncateLines(lines.slice(0, safeBudget), width);
|
|
29
|
+
if (safeBudget === 1)
|
|
30
|
+
return [theme.fg("dim", "…")];
|
|
31
|
+
return [...truncateLines(lines.slice(0, safeBudget - 1), width), theme.fg("dim", "…")];
|
|
32
|
+
}
|
|
33
|
+
/** Editor rows keep their top/bottom borders and follow the cursor when clipped. */
|
|
34
|
+
export function limitEditorLines(theme, lines, budget, width) {
|
|
35
|
+
const safeBudget = Math.max(0, Math.floor(budget));
|
|
36
|
+
if (safeBudget <= 0)
|
|
37
|
+
return [];
|
|
38
|
+
if (lines.length <= safeBudget)
|
|
39
|
+
return truncateLines(lines, width);
|
|
40
|
+
if (safeBudget === 1)
|
|
41
|
+
return [theme.fg("dim", "…")];
|
|
42
|
+
const topBorder = truncateToWidth(lines[0] ?? "", width, "", true);
|
|
43
|
+
const bottomBorder = truncateToWidth(lines[lines.length - 1] ?? "", width, "", true);
|
|
44
|
+
if (safeBudget === 2)
|
|
45
|
+
return [topBorder, bottomBorder];
|
|
46
|
+
const contentLines = lines.slice(1, -1);
|
|
47
|
+
const contentBudget = safeBudget - 2;
|
|
48
|
+
// Locate the cursor row: prefer the zero-width CURSOR_MARKER the editor
|
|
49
|
+
// emits while focused (the same mechanism pi-tui core uses for hardware
|
|
50
|
+
// cursor placement), falling back to the inverse-video fake cursor.
|
|
51
|
+
const cursorLineIndex = contentLines.findIndex((line) => line.includes(CURSOR_MARKER) || line.includes("\x1b[7m"));
|
|
52
|
+
const maxStart = Math.max(0, contentLines.length - contentBudget);
|
|
53
|
+
const start = cursorLineIndex >= 0
|
|
54
|
+
? Math.max(0, Math.min(cursorLineIndex - contentBudget + 1, maxStart))
|
|
55
|
+
: maxStart;
|
|
56
|
+
const visibleContentLines = contentLines.slice(start, start + contentBudget);
|
|
57
|
+
const markedContentLines = applyOverflowMarkers(theme, visibleContentLines, width, start > 0, start + contentBudget < contentLines.length);
|
|
58
|
+
return [topBorder, ...markedContentLines, bottomBorder];
|
|
59
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { QuestionOption } from "./single-select-layout";
|
|
2
|
+
export type AskResponse = {
|
|
3
|
+
kind: "selection";
|
|
4
|
+
selections: string[];
|
|
5
|
+
} | {
|
|
6
|
+
kind: "freeform";
|
|
7
|
+
text: string;
|
|
8
|
+
};
|
|
9
|
+
export interface AskToolDetails {
|
|
10
|
+
question: string;
|
|
11
|
+
context?: string | undefined;
|
|
12
|
+
options: QuestionOption[];
|
|
13
|
+
response: AskResponse | null;
|
|
14
|
+
cancelled: boolean;
|
|
15
|
+
}
|
|
16
|
+
export type BatchAnswer = {
|
|
17
|
+
status: "answered";
|
|
18
|
+
response: AskResponse;
|
|
19
|
+
} | {
|
|
20
|
+
status: "skipped";
|
|
21
|
+
};
|
|
22
|
+
/** Result details of a `questions` batch. Single-question results keep AskToolDetails. */
|
|
23
|
+
export interface AskBatchDetails {
|
|
24
|
+
kind: "batch";
|
|
25
|
+
/** The top-level question sent alongside questions, shown as the batch heading. */
|
|
26
|
+
heading?: string;
|
|
27
|
+
questions: Array<{
|
|
28
|
+
question: string;
|
|
29
|
+
context?: string | undefined;
|
|
30
|
+
options: QuestionOption[];
|
|
31
|
+
}>;
|
|
32
|
+
/** Index-aligned with `questions`; empty when the batch was cancelled. */
|
|
33
|
+
answers: BatchAnswer[];
|
|
34
|
+
cancelled: boolean;
|
|
35
|
+
}
|
|
36
|
+
export declare function isBatchDetails<T extends AskToolDetails | AskBatchDetails>(details: T): details is Extract<T, AskBatchDetails>;
|
|
37
|
+
export type AskUIResult = AskResponse;
|
|
38
|
+
export declare function formatOptionsForMessage(options: QuestionOption[]): string;
|
|
39
|
+
export declare function createFreeformResponse(text: string | null | undefined): AskResponse | null;
|
|
40
|
+
export declare function createSelectionResponse(selections: string[]): AskResponse | null;
|
|
41
|
+
export declare function formatResponseSummary(response: AskResponse): string;
|
|
42
|
+
export declare function isSelectionResponse(response: AskResponse): response is Extract<AskResponse, {
|
|
43
|
+
kind: "selection";
|
|
44
|
+
}>;
|
|
45
|
+
/** An empty dialog answer (dismissed or blank) cancels instead of recording a response. */
|
|
46
|
+
export declare function isCancelledInput(value: string | undefined): boolean;
|
package/dist/response.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function isBatchDetails(details) {
|
|
2
|
+
return details.kind === "batch";
|
|
3
|
+
}
|
|
4
|
+
export function formatOptionsForMessage(options) {
|
|
5
|
+
return options
|
|
6
|
+
.map((option, index) => {
|
|
7
|
+
const desc = option.description ? ` — ${option.description}` : "";
|
|
8
|
+
return `${index + 1}. ${option.title}${desc}`;
|
|
9
|
+
})
|
|
10
|
+
.join("\n");
|
|
11
|
+
}
|
|
12
|
+
export function createFreeformResponse(text) {
|
|
13
|
+
const trimmed = text?.trim();
|
|
14
|
+
return trimmed ? { kind: "freeform", text: trimmed } : null;
|
|
15
|
+
}
|
|
16
|
+
export function createSelectionResponse(selections) {
|
|
17
|
+
const normalizedSelections = selections.map((selection) => selection.trim()).filter(Boolean);
|
|
18
|
+
return normalizedSelections.length === 0
|
|
19
|
+
? null
|
|
20
|
+
: { kind: "selection", selections: normalizedSelections };
|
|
21
|
+
}
|
|
22
|
+
export function formatResponseSummary(response) {
|
|
23
|
+
return response.kind === "freeform" ? response.text : response.selections.join(", ");
|
|
24
|
+
}
|
|
25
|
+
export function isSelectionResponse(response) {
|
|
26
|
+
return response.kind === "selection";
|
|
27
|
+
}
|
|
28
|
+
/** An empty dialog answer (dismissed or blank) cancels instead of recording a response. */
|
|
29
|
+
export function isCancelledInput(value) {
|
|
30
|
+
return value === undefined || value.trim() === "";
|
|
31
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { QuestionOption } from "./single-select-layout";
|
|
3
|
+
export { StringEnum };
|
|
4
|
+
type AskOptionInput = QuestionOption | string;
|
|
5
|
+
export type AskDisplayMode = "overlay" | "inline";
|
|
6
|
+
interface BatchQuestionInput {
|
|
7
|
+
question: string;
|
|
8
|
+
context?: string;
|
|
9
|
+
options?: AskOptionInput[];
|
|
10
|
+
allowMultiple?: boolean;
|
|
11
|
+
allowFreeform?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface AskParams {
|
|
14
|
+
question?: string;
|
|
15
|
+
questions?: BatchQuestionInput[];
|
|
16
|
+
context?: string;
|
|
17
|
+
options?: AskOptionInput[];
|
|
18
|
+
allowMultiple?: boolean;
|
|
19
|
+
allowFreeform?: boolean;
|
|
20
|
+
displayMode?: AskDisplayMode;
|
|
21
|
+
timeout_ms?: number;
|
|
22
|
+
}
|
|
23
|
+
/** One validated entry of a `questions` batch. */
|
|
24
|
+
export interface BatchQuestion {
|
|
25
|
+
question: string;
|
|
26
|
+
context?: string | undefined;
|
|
27
|
+
options: QuestionOption[];
|
|
28
|
+
allowMultiple: boolean;
|
|
29
|
+
allowFreeform: boolean;
|
|
30
|
+
}
|
|
31
|
+
export declare function coerceOption(option: unknown): QuestionOption | null;
|
|
32
|
+
/** Options arrive as strings, objects, or numbers when a proxy mangled the schema. */
|
|
33
|
+
export declare function coerceOptions(rawOptions: unknown[]): QuestionOption[];
|
|
34
|
+
/** Nothing usable was left after normalization, so the prompt cannot open. */
|
|
35
|
+
export declare function malformedOptionsError(count: number, label?: string): Error;
|
|
36
|
+
/** A top-level question sent with questions is the batch's heading, not another question (#80). */
|
|
37
|
+
export declare function batchHeading(params: AskParams): string | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Validate and normalize a `questions` batch. Every problem throws before any
|
|
40
|
+
* UI opens or event fires (#67), with a message that tells the model how to
|
|
41
|
+
* correct the call.
|
|
42
|
+
*/
|
|
43
|
+
export declare function normalizeBatchQuestions(params: AskParams): BatchQuestion[];
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import { isRecord } from "@hheei/pi-ext-core";
|
|
3
|
+
import { BATCH_MAX_QUESTIONS, BATCH_MIN_QUESTIONS } from "./constants";
|
|
4
|
+
export { StringEnum };
|
|
5
|
+
// Key aliases models fall back to when a schema-mangling proxy (Google
|
|
6
|
+
// function calling, Codex-style backends, cmux) strips the option shape and
|
|
7
|
+
// the model has to guess. See issue #22.
|
|
8
|
+
const OPTION_TITLE_KEYS = ["title", "label", "text", "value", "name", "option"];
|
|
9
|
+
export function coerceOption(option) {
|
|
10
|
+
if (typeof option === "string" || typeof option === "number" || typeof option === "boolean") {
|
|
11
|
+
const title = String(option).trim();
|
|
12
|
+
return title ? { title } : null;
|
|
13
|
+
}
|
|
14
|
+
if (isRecord(option)) {
|
|
15
|
+
for (const key of OPTION_TITLE_KEYS) {
|
|
16
|
+
const value = option[key];
|
|
17
|
+
if (typeof value === "string" && value.trim()) {
|
|
18
|
+
const description = typeof option.description === "string" && option.description.trim()
|
|
19
|
+
? option.description
|
|
20
|
+
: undefined;
|
|
21
|
+
return description ? { title: value.trim(), description } : { title: value.trim() };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
/** Options arrive as strings, objects, or numbers when a proxy mangled the schema. */
|
|
28
|
+
export function coerceOptions(rawOptions) {
|
|
29
|
+
return rawOptions.map(coerceOption).filter((option) => option !== null);
|
|
30
|
+
}
|
|
31
|
+
/** Nothing usable was left after normalization, so the prompt cannot open. */
|
|
32
|
+
export function malformedOptionsError(count, label) {
|
|
33
|
+
return new Error(`All ${count} option(s)${label ? ` in ${label}` : ""} were malformed, so nothing could be shown to the user. ` +
|
|
34
|
+
`Each option must be a plain string or an object like { "title": "Short label", "description": "Optional detail" }. ` +
|
|
35
|
+
`Call ask_user again with corrected options.`);
|
|
36
|
+
}
|
|
37
|
+
/** A top-level question sent with questions is the batch's heading, not another question (#80). */
|
|
38
|
+
export function batchHeading(params) {
|
|
39
|
+
return typeof params.question === "string" && params.question.trim()
|
|
40
|
+
? params.question.trim()
|
|
41
|
+
: undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Validate and normalize a `questions` batch. Every problem throws before any
|
|
45
|
+
* UI opens or event fires (#67), with a message that tells the model how to
|
|
46
|
+
* correct the call.
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeBatchQuestions(params) {
|
|
49
|
+
// Top-level context/options/allowMultiple/allowFreeform are ignored here: entries carry their own,
|
|
50
|
+
// and models that echo schema defaults would otherwise loop on a validation error (#88).
|
|
51
|
+
const { questions } = params;
|
|
52
|
+
if (!Array.isArray(questions)) {
|
|
53
|
+
throw new Error(`questions must be an array of ${BATCH_MIN_QUESTIONS}-${BATCH_MAX_QUESTIONS} question objects.`);
|
|
54
|
+
}
|
|
55
|
+
if (questions.length < BATCH_MIN_QUESTIONS) {
|
|
56
|
+
throw new Error(`questions needs ${BATCH_MIN_QUESTIONS}-${BATCH_MAX_QUESTIONS} entries but got ${questions.length}. To ask one question, use question instead.`);
|
|
57
|
+
}
|
|
58
|
+
if (questions.length > BATCH_MAX_QUESTIONS) {
|
|
59
|
+
throw new Error(`questions accepts at most ${BATCH_MAX_QUESTIONS} entries but got ${questions.length}. Ask the rest in a later ask_user call.`);
|
|
60
|
+
}
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
return questions.map((entry, index) => {
|
|
63
|
+
const label = `questions[${index}]`;
|
|
64
|
+
const question = typeof entry?.question === "string" ? entry.question.trim() : "";
|
|
65
|
+
if (!question)
|
|
66
|
+
throw new Error(`${label}.question must be a non-empty string.`);
|
|
67
|
+
const key = question.toLowerCase();
|
|
68
|
+
if (seen.has(key)) {
|
|
69
|
+
throw new Error(`${label} repeats the question "${question}". Each question in a batch must be distinct.`);
|
|
70
|
+
}
|
|
71
|
+
seen.add(key);
|
|
72
|
+
const rawOptions = entry.options ?? [];
|
|
73
|
+
if (!Array.isArray(rawOptions))
|
|
74
|
+
throw new Error(`${label}.options must be an array.`);
|
|
75
|
+
const options = coerceOptions(rawOptions);
|
|
76
|
+
if (rawOptions.length > 0 && options.length === 0) {
|
|
77
|
+
throw malformedOptionsError(rawOptions.length, label);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
question,
|
|
81
|
+
context: typeof entry.context === "string" ? entry.context.trim() || undefined : undefined,
|
|
82
|
+
options,
|
|
83
|
+
allowMultiple: entry.allowMultiple ?? false,
|
|
84
|
+
allowFreeform: entry.allowFreeform ?? true,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type SettingField, type SettingsProvider } from "@hheei/pi-ext-core";
|
|
3
|
+
import type { AskDisplayMode } from "./schema";
|
|
4
|
+
export declare const ASK_USER_SETTINGS_PROVIDER_ID = "pi-ask-user";
|
|
5
|
+
/**
|
|
6
|
+
* The `askUser` section, defined once: the settings UI renders these fields and the tool
|
|
7
|
+
* resolves its own defaults from them. Values land in the global layer; a project layer
|
|
8
|
+
* still wins when the tool resolves preferences.
|
|
9
|
+
*/
|
|
10
|
+
export declare const ASK_USER_FIELDS: {
|
|
11
|
+
displayMode: SettingField<AskDisplayMode>;
|
|
12
|
+
};
|
|
13
|
+
export declare function createAskUserSettingsProvider(): SettingsProvider;
|
|
14
|
+
/** Registers the provider in the runtime registry; returns the unregister handle. */
|
|
15
|
+
export declare function registerAskUserSettings(pi: ExtensionAPI): () => void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createJsonSettingsStorage, getRuntimeSettingsRegistry, registerSettings, } from "@hheei/pi-ext-core";
|
|
2
|
+
import { SETTINGS_SECTION } from "./constants";
|
|
3
|
+
export const ASK_USER_SETTINGS_PROVIDER_ID = "pi-ask-user";
|
|
4
|
+
function enumField(id, label, description, defaultValue, values) {
|
|
5
|
+
return {
|
|
6
|
+
id,
|
|
7
|
+
label,
|
|
8
|
+
type: "enum",
|
|
9
|
+
defaultValue,
|
|
10
|
+
description,
|
|
11
|
+
options: values.map((value) => ({ value, label: value })),
|
|
12
|
+
parse: (draft) => (values.includes(draft) ? draft : defaultValue),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The `askUser` section, defined once: the settings UI renders these fields and the tool
|
|
17
|
+
* resolves its own defaults from them. Values land in the global layer; a project layer
|
|
18
|
+
* still wins when the tool resolves preferences.
|
|
19
|
+
*/
|
|
20
|
+
export const ASK_USER_FIELDS = {
|
|
21
|
+
displayMode: enumField("displayMode", "display mode", "Overlay shows a centered modal; inline renders the prompt in place.", "overlay", ["overlay", "inline"]),
|
|
22
|
+
};
|
|
23
|
+
export function createAskUserSettingsProvider() {
|
|
24
|
+
return {
|
|
25
|
+
id: ASK_USER_SETTINGS_PROVIDER_ID,
|
|
26
|
+
title: "ask_user",
|
|
27
|
+
origin: "@hheei/pi-ask-user",
|
|
28
|
+
description: "Interactive prompts: where the question is drawn.",
|
|
29
|
+
groups: [
|
|
30
|
+
{
|
|
31
|
+
id: SETTINGS_SECTION,
|
|
32
|
+
title: "",
|
|
33
|
+
fields: Object.values(ASK_USER_FIELDS),
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
storage: createJsonSettingsStorage({ group: SETTINGS_SECTION }),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Registers the provider in the runtime registry; returns the unregister handle. */
|
|
40
|
+
export function registerAskUserSettings(pi) {
|
|
41
|
+
return registerSettings(createAskUserSettingsProvider(), getRuntimeSettingsRegistry(pi));
|
|
42
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type KeybindingsManager } from "@earendil-works/pi-tui";
|
|
2
|
+
export declare function matchesSelectUp(data: string, keybindings: KeybindingsManager): boolean;
|
|
3
|
+
export declare function matchesSelectDown(data: string, keybindings: KeybindingsManager): boolean;
|
|
4
|
+
/** PageUp/PageDown move by a viewport. The prompt pane owns these; lists own the arrows. */
|
|
5
|
+
export declare function pageDelta(data: string, pageRows: number): number;
|
|
6
|
+
/** A 1-9 digit key, including the CSI-u form the kitty keyboard protocol sends. */
|
|
7
|
+
export declare function digitKey(data: string): number | null;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { decodeKittyPrintable, Key, matchesKey, } from "@earendil-works/pi-tui";
|
|
2
|
+
// tab/shift+tab mirror the host's select lists; a batch prompt takes tab for paging first.
|
|
3
|
+
export function matchesSelectUp(data, keybindings) {
|
|
4
|
+
return keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.shift("tab"));
|
|
5
|
+
}
|
|
6
|
+
export function matchesSelectDown(data, keybindings) {
|
|
7
|
+
return keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab);
|
|
8
|
+
}
|
|
9
|
+
/** PageUp/PageDown move by a viewport. The prompt pane owns these; lists own the arrows. */
|
|
10
|
+
export function pageDelta(data, pageRows) {
|
|
11
|
+
if (matchesKey(data, Key.pageUp))
|
|
12
|
+
return -pageRows;
|
|
13
|
+
if (matchesKey(data, Key.pageDown))
|
|
14
|
+
return pageRows;
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
/** A 1-9 digit key, including the CSI-u form the kitty keyboard protocol sends. */
|
|
18
|
+
export function digitKey(data) {
|
|
19
|
+
const key = decodeKittyPrintable(data) ?? data;
|
|
20
|
+
return key.length === 1 && key >= "1" && key <= "9" ? Number(key) : null;
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface QuestionOption {
|
|
2
|
+
title: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
}
|
|
5
|
+
/** What a rendered row is, so the list styles it without sniffing its text. */
|
|
6
|
+
export type OptionRowKind = "option" | "description" | "helper" | "indicator";
|
|
7
|
+
export interface AnnotatedRow {
|
|
8
|
+
line: string;
|
|
9
|
+
selected: boolean;
|
|
10
|
+
kind: OptionRowKind;
|
|
11
|
+
}
|
|
12
|
+
export interface RenderSingleSelectRowsParams {
|
|
13
|
+
options: QuestionOption[];
|
|
14
|
+
selectedIndex: number;
|
|
15
|
+
width: number;
|
|
16
|
+
allowFreeform: boolean;
|
|
17
|
+
maxRows?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function renderSingleSelectRows({ options, selectedIndex, width, allowFreeform, maxRows, }: RenderSingleSelectRowsParams): AnnotatedRow[];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
import { FREEFORM_HINT, FREEFORM_LABEL } from "./constants";
|
|
3
|
+
import { visibleBlockWindow } from "./option-rows";
|
|
4
|
+
/** Wraps `text` under `prefix`, indenting continuation lines to the same column. */
|
|
5
|
+
function wrappedRow(text, prefix, width, kind) {
|
|
6
|
+
const indent = " ".repeat(prefix.length);
|
|
7
|
+
return wrapTextWithAnsi(text, Math.max(8, width - prefix.length)).map((line, index) => ({
|
|
8
|
+
text: `${index === 0 ? prefix : indent}${line}`.trimEnd(),
|
|
9
|
+
kind,
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
function buildItemBlocks(options, width, allowFreeform, selectedIndex) {
|
|
13
|
+
const normalizedWidth = Math.max(12, width);
|
|
14
|
+
const allItems = options.map((option, index) => ({
|
|
15
|
+
type: "option",
|
|
16
|
+
option,
|
|
17
|
+
index,
|
|
18
|
+
}));
|
|
19
|
+
if (allowFreeform)
|
|
20
|
+
allItems.push({ type: "freeform" });
|
|
21
|
+
return allItems.map((item, itemIndex) => {
|
|
22
|
+
const pointer = itemIndex === selectedIndex ? "→" : " ";
|
|
23
|
+
const helperPrefix = `${pointer} `;
|
|
24
|
+
if (item.type === "freeform") {
|
|
25
|
+
const label = `${FREEFORM_LABEL} — ${FREEFORM_HINT}`;
|
|
26
|
+
return { itemIndex, lines: wrappedRow(label, helperPrefix, normalizedWidth, "helper") };
|
|
27
|
+
}
|
|
28
|
+
const numberPrefix = `${pointer} ${item.index + 1}. `;
|
|
29
|
+
const lines = wrappedRow(item.option.title, numberPrefix, normalizedWidth, "option");
|
|
30
|
+
if (item.option.description) {
|
|
31
|
+
lines.push(...wrappedRow(item.option.description, " ", normalizedWidth, "description"));
|
|
32
|
+
}
|
|
33
|
+
return { itemIndex, lines };
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function flatten(blocks, selectedIndex) {
|
|
37
|
+
return blocks.flatMap((block) => block.lines.map((line) => ({
|
|
38
|
+
line: line.text,
|
|
39
|
+
kind: line.kind,
|
|
40
|
+
selected: block.itemIndex === selectedIndex,
|
|
41
|
+
})));
|
|
42
|
+
}
|
|
43
|
+
export function renderSingleSelectRows({ options, selectedIndex, width, allowFreeform, maxRows, }) {
|
|
44
|
+
const itemCount = options.length + (allowFreeform ? 1 : 0);
|
|
45
|
+
const blocks = buildItemBlocks(options, width, allowFreeform, selectedIndex);
|
|
46
|
+
const allRows = flatten(blocks, selectedIndex);
|
|
47
|
+
if (!Number.isFinite(maxRows) || !maxRows || maxRows <= 0 || allRows.length <= maxRows) {
|
|
48
|
+
return allRows;
|
|
49
|
+
}
|
|
50
|
+
const safeMaxRows = Math.max(1, Math.floor(maxRows));
|
|
51
|
+
const window = visibleBlockWindow(blocks, selectedIndex, safeMaxRows);
|
|
52
|
+
const visible = flatten(window, selectedIndex);
|
|
53
|
+
if (window !== blocks && safeMaxRows > 1) {
|
|
54
|
+
visible.push({
|
|
55
|
+
line: ` (${selectedIndex + 1}/${itemCount})`,
|
|
56
|
+
kind: "indicator",
|
|
57
|
+
selected: false,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return visible.slice(0, safeMaxRows);
|
|
61
|
+
}
|