@hheei/pi-ask-user 0.0.0-stage → 0.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/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 +59 -5
- package/skills/ask-user/SKILL.md +189 -0
- package/skills/ask-user/references/ask-user-skill-extension-spec.md +106 -0
package/dist/format.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { formatOptionsForMessage, formatResponseSummary, isSelectionResponse, } from "./response";
|
|
2
|
+
export function formatBatchAnswers(details) {
|
|
3
|
+
const heading = details.heading ? [details.heading] : [];
|
|
4
|
+
const lines = details.questions.map((subject, index) => {
|
|
5
|
+
const answer = details.answers[index];
|
|
6
|
+
const summary = answer?.status === "answered" ? formatResponseSummary(answer.response) : "(skipped)";
|
|
7
|
+
return `${index + 1}. ${subject.question} \u2192 ${summary}`;
|
|
8
|
+
});
|
|
9
|
+
const answered = details.answers.filter((answer) => answer.status === "answered").length;
|
|
10
|
+
return [
|
|
11
|
+
...heading,
|
|
12
|
+
`User answered ${answered} of ${details.questions.length} questions:`,
|
|
13
|
+
...lines,
|
|
14
|
+
].join("\n");
|
|
15
|
+
}
|
|
16
|
+
/** Non-interactive hosts get the whole prompt as text, so the model can still ask. */
|
|
17
|
+
export function formatSingleForMessage(input) {
|
|
18
|
+
const optionText = input.options.length > 0 ? `\n\nOptions:\n${formatOptionsForMessage(input.options)}` : "";
|
|
19
|
+
const freeformHint = input.allowFreeform ? "\n\nYou can also answer freely." : "";
|
|
20
|
+
const contextText = input.context ? `\n\nContext:\n${input.context}` : "";
|
|
21
|
+
return `Ask requires interactive mode. Please answer:\n\n${input.question}${contextText}${optionText}${freeformHint}`;
|
|
22
|
+
}
|
|
23
|
+
export function formatBatchForMessage(questions, heading) {
|
|
24
|
+
const blocks = questions.map((entry, index) => {
|
|
25
|
+
const lines = [`${index + 1}. ${entry.question}`];
|
|
26
|
+
if (entry.context)
|
|
27
|
+
lines.push(` Context: ${entry.context.replace(/\n/g, "\n ")}`);
|
|
28
|
+
if (entry.options.length > 0) {
|
|
29
|
+
lines.push(` Options${entry.allowMultiple ? " (choose one or more)" : ""}:`);
|
|
30
|
+
lines.push(...formatOptionsForMessage(entry.options)
|
|
31
|
+
.split("\n")
|
|
32
|
+
.map((line) => ` ${line}`));
|
|
33
|
+
if (entry.allowFreeform)
|
|
34
|
+
lines.push(" You can also answer freely.");
|
|
35
|
+
}
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
});
|
|
38
|
+
const intro = heading ? `\n\n${heading}` : "";
|
|
39
|
+
return `Ask requires interactive mode. Please answer these questions:${intro}\n\n${blocks.join("\n\n")}`;
|
|
40
|
+
}
|
|
41
|
+
/** Expanded-result block: every option with the selected ones marked. */
|
|
42
|
+
export function formatOptionMarkers(theme, options, response, indent = "") {
|
|
43
|
+
const selectedTitles = new Set(response.selections);
|
|
44
|
+
let text = `\n${indent}${theme.fg("dim", "Options:")}`;
|
|
45
|
+
for (const opt of options) {
|
|
46
|
+
const desc = opt.description ? ` \u2014 ${opt.description}` : "";
|
|
47
|
+
const marker = selectedTitles.has(opt.title)
|
|
48
|
+
? theme.fg("success", "\u25cf")
|
|
49
|
+
: theme.fg("dim", "\u25cb");
|
|
50
|
+
text += `\n${indent} ${marker} ${theme.fg("dim", opt.title)}${theme.fg("dim", desc)}`;
|
|
51
|
+
}
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
export function formatBatchResult(theme, details, expanded) {
|
|
55
|
+
if (details.cancelled)
|
|
56
|
+
return theme.fg("warning", "Cancelled");
|
|
57
|
+
const answered = details.answers.filter((answer) => answer.status === "answered").length;
|
|
58
|
+
let text = theme.fg("success", "\u2713 ") +
|
|
59
|
+
theme.fg("accent", `${answered} of ${details.questions.length} answered`);
|
|
60
|
+
details.questions.forEach((subject, index) => {
|
|
61
|
+
const answer = details.answers[index];
|
|
62
|
+
if (!answer)
|
|
63
|
+
return;
|
|
64
|
+
text += `\n${theme.fg("dim", `${index + 1}.`)} ${theme.fg("muted", subject.question)}${theme.fg("dim", " \u2192 ")}`;
|
|
65
|
+
if (answer.status === "skipped") {
|
|
66
|
+
text += theme.fg("warning", "(skipped)");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const response = answer.response;
|
|
70
|
+
if (response.kind === "freeform") {
|
|
71
|
+
text += theme.fg("muted", "(wrote) ");
|
|
72
|
+
}
|
|
73
|
+
text += theme.fg("accent", formatResponseSummary(response));
|
|
74
|
+
if (!expanded)
|
|
75
|
+
return;
|
|
76
|
+
if (subject.context) {
|
|
77
|
+
text += `\n ${theme.fg("dim", subject.context)}`;
|
|
78
|
+
}
|
|
79
|
+
if (isSelectionResponse(response) && subject.options.length > 0) {
|
|
80
|
+
text += formatOptionMarkers(theme, subject.options, response, " ");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return text;
|
|
84
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, type KeybindingsManager } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { QuestionOption } from "./single-select-layout";
|
|
4
|
+
export declare class MultiSelectList implements Component {
|
|
5
|
+
private options;
|
|
6
|
+
private theme;
|
|
7
|
+
private keybindings;
|
|
8
|
+
private readonly rows;
|
|
9
|
+
private checked;
|
|
10
|
+
private cachedWidth;
|
|
11
|
+
private cachedLines;
|
|
12
|
+
onCancel?: () => void;
|
|
13
|
+
onSubmit?: (result: string[]) => void;
|
|
14
|
+
onEnterFreeform?: () => void;
|
|
15
|
+
constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme, keybindings: KeybindingsManager);
|
|
16
|
+
setMaxVisibleRows(count: number): void;
|
|
17
|
+
invalidate(): void;
|
|
18
|
+
private toggle;
|
|
19
|
+
handleInput(data: string): void;
|
|
20
|
+
/** Space on the selected row: tick the option, or open the custom response. */
|
|
21
|
+
private activate;
|
|
22
|
+
/** Submit the ticked options, or the selected one when none is ticked. */
|
|
23
|
+
submit(): void;
|
|
24
|
+
render(width: number): string[];
|
|
25
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Key, matchesKey, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
2
|
+
import { fitRow } from "@hheei/pi-ext-core";
|
|
3
|
+
import { FREEFORM_HINT, FREEFORM_LABEL } from "./constants";
|
|
4
|
+
import { OptionRows, visibleBlockWindow } from "./option-rows";
|
|
5
|
+
import { digitKey, matchesSelectDown, matchesSelectUp } from "./shortcuts";
|
|
6
|
+
export class MultiSelectList {
|
|
7
|
+
options;
|
|
8
|
+
theme;
|
|
9
|
+
keybindings;
|
|
10
|
+
rows;
|
|
11
|
+
checked = new Set();
|
|
12
|
+
cachedWidth;
|
|
13
|
+
cachedLines;
|
|
14
|
+
onCancel;
|
|
15
|
+
onSubmit;
|
|
16
|
+
onEnterFreeform;
|
|
17
|
+
constructor(options, allowFreeform, theme, keybindings) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
this.theme = theme;
|
|
20
|
+
this.keybindings = keybindings;
|
|
21
|
+
this.rows = new OptionRows(allowFreeform, 10);
|
|
22
|
+
}
|
|
23
|
+
setMaxVisibleRows(count) {
|
|
24
|
+
if (this.rows.setMaxVisibleRows(count))
|
|
25
|
+
this.invalidate();
|
|
26
|
+
}
|
|
27
|
+
invalidate() {
|
|
28
|
+
this.cachedWidth = undefined;
|
|
29
|
+
this.cachedLines = undefined;
|
|
30
|
+
}
|
|
31
|
+
toggle(index) {
|
|
32
|
+
if (index < 0 || index >= this.options.length)
|
|
33
|
+
return;
|
|
34
|
+
if (this.checked.has(index))
|
|
35
|
+
this.checked.delete(index);
|
|
36
|
+
else
|
|
37
|
+
this.checked.add(index);
|
|
38
|
+
}
|
|
39
|
+
handleInput(data) {
|
|
40
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
41
|
+
this.onCancel?.();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const optionCount = this.options.length;
|
|
45
|
+
const count = this.rows.count(optionCount);
|
|
46
|
+
if (count === 0) {
|
|
47
|
+
this.onCancel?.();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (matchesSelectUp(data, this.keybindings) && this.rows.moveSelection(-1, count)) {
|
|
51
|
+
this.invalidate();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (matchesSelectDown(data, this.keybindings) && this.rows.moveSelection(1, count)) {
|
|
55
|
+
this.invalidate();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const digit = digitKey(data);
|
|
59
|
+
if (digit !== null) {
|
|
60
|
+
if (digit <= optionCount) {
|
|
61
|
+
this.toggle(digit - 1);
|
|
62
|
+
this.rows.selected = Math.min(digit - 1, count - 1);
|
|
63
|
+
this.invalidate();
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (matchesKey(data, Key.space)) {
|
|
68
|
+
this.activate();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
72
|
+
if (this.rows.isFreeformRow(this.rows.selected, optionCount)) {
|
|
73
|
+
this.activate();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.submit();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Space on the selected row: tick the option, or open the custom response. */
|
|
80
|
+
activate() {
|
|
81
|
+
const index = this.rows.selected;
|
|
82
|
+
if (this.rows.isFreeformRow(index, this.options.length)) {
|
|
83
|
+
this.onEnterFreeform?.();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.toggle(index);
|
|
87
|
+
this.invalidate();
|
|
88
|
+
}
|
|
89
|
+
/** Submit the ticked options, or the selected one when none is ticked. */
|
|
90
|
+
submit() {
|
|
91
|
+
const selectedTitles = Array.from(this.checked)
|
|
92
|
+
.sort((a, b) => a - b)
|
|
93
|
+
.map((i) => this.options[i]?.title)
|
|
94
|
+
.filter((title) => !!title);
|
|
95
|
+
const fallback = this.options[this.rows.selected]?.title;
|
|
96
|
+
const result = selectedTitles.length > 0 ? selectedTitles : fallback ? [fallback] : [];
|
|
97
|
+
if (result.length > 0)
|
|
98
|
+
this.onSubmit?.(result);
|
|
99
|
+
else
|
|
100
|
+
this.onCancel?.();
|
|
101
|
+
}
|
|
102
|
+
render(width) {
|
|
103
|
+
if (this.cachedLines && this.cachedWidth === width)
|
|
104
|
+
return this.cachedLines;
|
|
105
|
+
const theme = this.theme;
|
|
106
|
+
const optionCount = this.options.length;
|
|
107
|
+
const count = this.rows.count(optionCount);
|
|
108
|
+
if (count === 0) {
|
|
109
|
+
this.cachedLines = [theme.fg("warning", "No options")];
|
|
110
|
+
this.cachedWidth = width;
|
|
111
|
+
return this.cachedLines;
|
|
112
|
+
}
|
|
113
|
+
this.rows.clampSelection(count);
|
|
114
|
+
const blocks = [];
|
|
115
|
+
for (let i = 0; i < count; i++) {
|
|
116
|
+
const isSelected = i === this.rows.selected;
|
|
117
|
+
const prefix = isSelected ? theme.fg("accent", "→") : " ";
|
|
118
|
+
const block = [];
|
|
119
|
+
if (this.rows.isFreeformRow(i, optionCount)) {
|
|
120
|
+
const label = theme.fg("text", theme.bold(FREEFORM_LABEL));
|
|
121
|
+
const desc = theme.fg("muted", FREEFORM_HINT);
|
|
122
|
+
block.push(fitRow(`${prefix} ${label} ${theme.fg("dim", "—")} ${desc}`, width, ""));
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const option = this.options[i];
|
|
126
|
+
if (option) {
|
|
127
|
+
const checkbox = this.checked.has(i)
|
|
128
|
+
? theme.fg("success", "[✓]")
|
|
129
|
+
: theme.fg("dim", "[ ]");
|
|
130
|
+
const num = theme.fg("dim", `${i + 1}.`);
|
|
131
|
+
const title = isSelected
|
|
132
|
+
? theme.fg("accent", theme.bold(option.title))
|
|
133
|
+
: theme.fg("text", theme.bold(option.title));
|
|
134
|
+
block.push(fitRow(`${prefix} ${num} ${checkbox} ${title}`, width, ""));
|
|
135
|
+
if (option.description) {
|
|
136
|
+
const indent = " ";
|
|
137
|
+
for (const line of wrapTextWithAnsi(option.description, Math.max(8, width - indent.length))) {
|
|
138
|
+
block.push(fitRow(indent + theme.fg("muted", line), width, ""));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
blocks.push({ itemIndex: i, lines: block });
|
|
144
|
+
}
|
|
145
|
+
const maxRows = this.rows.visibleRows;
|
|
146
|
+
const window = visibleBlockWindow(blocks, this.rows.selected, maxRows);
|
|
147
|
+
const lines = window.flatMap((block) => block.lines);
|
|
148
|
+
if (window !== blocks && maxRows > 1) {
|
|
149
|
+
lines.push(theme.fg("dim", fitRow(` (${this.rows.selected + 1}/${count})`, width, "")));
|
|
150
|
+
}
|
|
151
|
+
this.cachedWidth = width;
|
|
152
|
+
this.cachedLines = lines;
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row model shared by the single- and multi-select lists: option rows followed
|
|
3
|
+
* by the optional freeform row, plus the selection cursor and the row budget.
|
|
4
|
+
*/
|
|
5
|
+
export declare class OptionRows {
|
|
6
|
+
readonly allowFreeform: boolean;
|
|
7
|
+
selected: number;
|
|
8
|
+
private maxVisibleRows;
|
|
9
|
+
constructor(allowFreeform: boolean, maxVisibleRows: number);
|
|
10
|
+
get visibleRows(): number;
|
|
11
|
+
/** Rows shown for `optionCount` options: the options plus the trailing freeform row. */
|
|
12
|
+
count(optionCount: number): number;
|
|
13
|
+
freeformIndex(optionCount: number): number;
|
|
14
|
+
isFreeformRow(index: number, optionCount: number): boolean;
|
|
15
|
+
setMaxVisibleRows(rows: number): boolean;
|
|
16
|
+
/** Move the cursor by `delta` rows, wrapping around `count` rows. */
|
|
17
|
+
moveSelection(delta: number, count: number): boolean;
|
|
18
|
+
/** Keep the cursor inside `count` rows after the row set changed. */
|
|
19
|
+
clampSelection(count: number): boolean;
|
|
20
|
+
}
|
|
21
|
+
/** One item's rendered rows: an option or the freeform row. */
|
|
22
|
+
export interface RowBlock<T = string> {
|
|
23
|
+
itemIndex: number;
|
|
24
|
+
lines: T[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The blocks that fit in `maxRows`, keeping the selected block visible and
|
|
28
|
+
* filling the budget with following blocks first and preceding ones after
|
|
29
|
+
* that. The last row is reserved for the caller's overflow indicator, so the
|
|
30
|
+
* selected block is cut when it alone exceeds the budget. Returns the input
|
|
31
|
+
* array unchanged when everything fits.
|
|
32
|
+
*/
|
|
33
|
+
export declare function visibleBlockWindow<T>(blocks: RowBlock<T>[], selectedIndex: number, maxRows: number): RowBlock<T>[];
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row model shared by the single- and multi-select lists: option rows followed
|
|
3
|
+
* by the optional freeform row, plus the selection cursor and the row budget.
|
|
4
|
+
*/
|
|
5
|
+
export class OptionRows {
|
|
6
|
+
allowFreeform;
|
|
7
|
+
selected = 0;
|
|
8
|
+
maxVisibleRows;
|
|
9
|
+
constructor(allowFreeform, maxVisibleRows) {
|
|
10
|
+
this.allowFreeform = allowFreeform;
|
|
11
|
+
this.maxVisibleRows = maxVisibleRows;
|
|
12
|
+
}
|
|
13
|
+
get visibleRows() {
|
|
14
|
+
return this.maxVisibleRows;
|
|
15
|
+
}
|
|
16
|
+
/** Rows shown for `optionCount` options: the options plus the trailing freeform row. */
|
|
17
|
+
count(optionCount) {
|
|
18
|
+
return optionCount + (this.allowFreeform ? 1 : 0);
|
|
19
|
+
}
|
|
20
|
+
freeformIndex(optionCount) {
|
|
21
|
+
return optionCount;
|
|
22
|
+
}
|
|
23
|
+
isFreeformRow(index, optionCount) {
|
|
24
|
+
return this.allowFreeform && index === this.freeformIndex(optionCount);
|
|
25
|
+
}
|
|
26
|
+
setMaxVisibleRows(rows) {
|
|
27
|
+
const next = Math.max(1, Math.floor(rows));
|
|
28
|
+
if (next === this.maxVisibleRows)
|
|
29
|
+
return false;
|
|
30
|
+
this.maxVisibleRows = next;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
/** Move the cursor by `delta` rows, wrapping around `count` rows. */
|
|
34
|
+
moveSelection(delta, count) {
|
|
35
|
+
if (count <= 0)
|
|
36
|
+
return false;
|
|
37
|
+
const next = (this.selected + delta + count) % count;
|
|
38
|
+
if (next === this.selected)
|
|
39
|
+
return false;
|
|
40
|
+
this.selected = next;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
/** Keep the cursor inside `count` rows after the row set changed. */
|
|
44
|
+
clampSelection(count) {
|
|
45
|
+
const next = count > 0 ? Math.max(0, Math.min(this.selected, count - 1)) : 0;
|
|
46
|
+
if (next === this.selected)
|
|
47
|
+
return false;
|
|
48
|
+
this.selected = next;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The blocks that fit in `maxRows`, keeping the selected block visible and
|
|
54
|
+
* filling the budget with following blocks first and preceding ones after
|
|
55
|
+
* that. The last row is reserved for the caller's overflow indicator, so the
|
|
56
|
+
* selected block is cut when it alone exceeds the budget. Returns the input
|
|
57
|
+
* array unchanged when everything fits.
|
|
58
|
+
*/
|
|
59
|
+
export function visibleBlockWindow(blocks, selectedIndex, maxRows) {
|
|
60
|
+
const totalRows = blocks.reduce((sum, block) => sum + block.lines.length, 0);
|
|
61
|
+
if (totalRows <= maxRows)
|
|
62
|
+
return blocks;
|
|
63
|
+
const availableRows = maxRows > 1 ? maxRows - 1 : 1;
|
|
64
|
+
const selected = blocks[selectedIndex] ?? blocks[0];
|
|
65
|
+
if (!selected)
|
|
66
|
+
return [];
|
|
67
|
+
if (selected.lines.length >= availableRows) {
|
|
68
|
+
return [{ itemIndex: selected.itemIndex, lines: selected.lines.slice(0, availableRows) }];
|
|
69
|
+
}
|
|
70
|
+
let startIndex = selectedIndex;
|
|
71
|
+
let endIndex = selectedIndex + 1;
|
|
72
|
+
let usedRows = selected.lines.length;
|
|
73
|
+
while (true) {
|
|
74
|
+
const nextBlock = blocks[endIndex];
|
|
75
|
+
if (nextBlock && usedRows + nextBlock.lines.length <= availableRows) {
|
|
76
|
+
usedRows += nextBlock.lines.length;
|
|
77
|
+
endIndex += 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const previousBlock = blocks[startIndex - 1];
|
|
81
|
+
if (previousBlock && usedRows + previousBlock.lines.length <= availableRows) {
|
|
82
|
+
startIndex -= 1;
|
|
83
|
+
usedRows += previousBlock.lines.length;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
return blocks.slice(startIndex, endIndex);
|
|
89
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { SETTINGS_SECTION } from "./constants";
|
|
2
|
+
import type { AskDisplayMode, AskParams } from "./schema";
|
|
3
|
+
export { SETTINGS_SECTION };
|
|
4
|
+
export interface PromptSettings {
|
|
5
|
+
displayMode: AskDisplayMode;
|
|
6
|
+
}
|
|
7
|
+
/** Resolve presentation preferences: call parameter, then the askUser section of ext_settings.json, then the field default. */
|
|
8
|
+
export declare function resolvePromptSettings(params: AskParams, signal?: AbortSignal): Promise<PromptSettings>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defaultExtensionSettingsPaths, readMergedJsonSettingsSection, } from "@hheei/pi-ext-core";
|
|
2
|
+
import { SETTINGS_SECTION } from "./constants";
|
|
3
|
+
import { ASK_USER_FIELDS } from "./settings-provider";
|
|
4
|
+
export { SETTINGS_SECTION };
|
|
5
|
+
function pickEnum(raw, field) {
|
|
6
|
+
// ext-core types option values as SettingPrimitive; an enum field only ever lists its own T.
|
|
7
|
+
const matched = field.options?.find((option) => option.value === raw)?.value;
|
|
8
|
+
return matched ?? field.defaultValue;
|
|
9
|
+
}
|
|
10
|
+
/** Read on every ask_user call so edits apply without restarting Pi. */
|
|
11
|
+
async function readAskUserSettings(signal) {
|
|
12
|
+
const { merged } = await readMergedJsonSettingsSection({
|
|
13
|
+
paths: defaultExtensionSettingsPaths(),
|
|
14
|
+
section: SETTINGS_SECTION,
|
|
15
|
+
...(signal ? { signal } : {}),
|
|
16
|
+
});
|
|
17
|
+
return merged;
|
|
18
|
+
}
|
|
19
|
+
/** Resolve presentation preferences: call parameter, then the askUser section of ext_settings.json, then the field default. */
|
|
20
|
+
export async function resolvePromptSettings(params, signal) {
|
|
21
|
+
const raw = await readAskUserSettings(signal);
|
|
22
|
+
return {
|
|
23
|
+
displayMode: params.displayMode ?? pickEnum(raw.displayMode, ASK_USER_FIELDS.displayMode),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AgentToolResult, ExtensionAPI, Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type ToolPresentation, type ToolPresentationContext } from "@hheei/pi-ext-core";
|
|
3
|
+
/** Presentation facts for one ask_user call or result; ext-ui frames them, the host shows them plain. */
|
|
4
|
+
export declare function presentAskUser(args: unknown, result: AgentToolResult<unknown> | undefined, theme: Theme, context: ToolPresentationContext): ToolPresentation;
|
|
5
|
+
/** Native renderers plus the presentation provider ext-ui decorates when installed. */
|
|
6
|
+
export declare function createAskUserRenderers(pi: ExtensionAPI): ToolRenderers;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
2
|
+
import { createToolPresentationRenderers, } from "@hheei/pi-ext-core";
|
|
3
|
+
import { formatBatchResult, formatOptionMarkers } from "./format";
|
|
4
|
+
import { formatResponseSummary, isBatchDetails, isSelectionResponse, } from "./response";
|
|
5
|
+
import { coerceOptions } from "./schema";
|
|
6
|
+
function detailsOf(result) {
|
|
7
|
+
const details = result?.details;
|
|
8
|
+
return typeof details === "object" && details !== null ? details : undefined;
|
|
9
|
+
}
|
|
10
|
+
function errorText(result, details) {
|
|
11
|
+
return (details?.error ??
|
|
12
|
+
result?.content
|
|
13
|
+
?.map((part) => (part.type === "text" ? part.text : ""))
|
|
14
|
+
.join("\n")
|
|
15
|
+
.trim() ??
|
|
16
|
+
"");
|
|
17
|
+
}
|
|
18
|
+
function callSections(args, theme) {
|
|
19
|
+
const questions = Array.isArray(args.questions) ? args.questions : undefined;
|
|
20
|
+
if (questions === undefined) {
|
|
21
|
+
const question = typeof args.question === "string" ? args.question.trim() : "";
|
|
22
|
+
const lines = coerceOptions(Array.isArray(args.options) ? args.options : []).map((option, index) => theme.fg("text", `${index + 1}. ${option.title}`));
|
|
23
|
+
return {
|
|
24
|
+
summary: question,
|
|
25
|
+
sections: lines.length === 0
|
|
26
|
+
? []
|
|
27
|
+
: [{ railToken: "muted", content: [theme.fg("dim", "Options:"), ...lines] }],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const heading = typeof args.question === "string" ? args.question.trim() : "";
|
|
31
|
+
const lines = questions.map((entry, index) => {
|
|
32
|
+
const record = (entry ?? {});
|
|
33
|
+
const question = typeof record.question === "string" ? record.question : "";
|
|
34
|
+
const optionCount = Array.isArray(record.options) ? record.options.length : 0;
|
|
35
|
+
const notes = optionCount > 0 ? ` (${optionCount} option(s))` : "";
|
|
36
|
+
return theme.fg("text", `${index + 1}. ${question}${notes}`);
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
summary: `${questions.length} questions${heading ? ` · ${heading}` : ""}`,
|
|
40
|
+
sections: lines.length === 0 ? [] : [{ railToken: "muted", content: lines }],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function singleResult(details, theme) {
|
|
44
|
+
const response = details.response;
|
|
45
|
+
if (details.cancelled || response === null) {
|
|
46
|
+
return {
|
|
47
|
+
summary: "cancelled",
|
|
48
|
+
status: "warn",
|
|
49
|
+
collapsedRows: [theme.fg("warning", "Cancelled")],
|
|
50
|
+
output: "Cancelled",
|
|
51
|
+
copy: { primary: "Cancelled" },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const summary = formatResponseSummary(response);
|
|
55
|
+
const prefix = response.kind === "freeform" ? "(wrote) " : "";
|
|
56
|
+
const sections = [];
|
|
57
|
+
const plain = [`✓ ${prefix}${summary}`];
|
|
58
|
+
const addSection = (railToken, lines) => {
|
|
59
|
+
if (lines.length === 0)
|
|
60
|
+
return;
|
|
61
|
+
sections.push({ railToken, content: lines });
|
|
62
|
+
plain.push(...lines);
|
|
63
|
+
};
|
|
64
|
+
addSection("dim", [theme.fg("dim", `Q: ${details.question}`)]);
|
|
65
|
+
if (details.context)
|
|
66
|
+
addSection("dim", [theme.fg("dim", details.context)]);
|
|
67
|
+
if (isSelectionResponse(response)) {
|
|
68
|
+
addSection("accent", formatOptionMarkers(theme, details.options, response).split("\n"));
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
summary,
|
|
72
|
+
status: "ok",
|
|
73
|
+
collapsedRows: [
|
|
74
|
+
`${theme.fg("success", "✓ ")}${theme.fg("muted", prefix)}${theme.fg("accent", summary)}`,
|
|
75
|
+
],
|
|
76
|
+
sections,
|
|
77
|
+
output: stripTerminalSequences(plain.join("\n")),
|
|
78
|
+
copy: { primary: summary },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function batchResult(details, theme) {
|
|
82
|
+
const collapsed = formatBatchResult(theme, details, false);
|
|
83
|
+
const expanded = formatBatchResult(theme, details, true);
|
|
84
|
+
const answered = details.answers.filter((answer) => answer.status === "answered").length;
|
|
85
|
+
return {
|
|
86
|
+
summary: details.cancelled
|
|
87
|
+
? "cancelled"
|
|
88
|
+
: `${answered} of ${details.questions.length} answered`,
|
|
89
|
+
status: details.cancelled ? "warn" : "ok",
|
|
90
|
+
collapsedRows: collapsed.split("\n"),
|
|
91
|
+
sections: expanded === collapsed ? [] : [{ railToken: "dim", content: expanded.split("\n") }],
|
|
92
|
+
output: stripTerminalSequences(expanded),
|
|
93
|
+
copy: { primary: stripTerminalSequences(collapsed) },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Presentation facts for one ask_user call or result; ext-ui frames them, the host shows them plain. */
|
|
97
|
+
export function presentAskUser(args, result, theme, context) {
|
|
98
|
+
const details = detailsOf(result);
|
|
99
|
+
const failed = result?.isError === true || details?.error !== undefined;
|
|
100
|
+
if (failed) {
|
|
101
|
+
const message = errorText(result, details);
|
|
102
|
+
return {
|
|
103
|
+
summary: message === "" ? "failed" : (message.split("\n")[0] ?? "failed"),
|
|
104
|
+
status: "error",
|
|
105
|
+
output: message === "" ? "ask_user failed" : message,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (result === undefined) {
|
|
109
|
+
const call = callSections(typeof args === "object" && args !== null ? args : {}, theme);
|
|
110
|
+
return { summary: call.summary, status: "running", sections: call.sections };
|
|
111
|
+
}
|
|
112
|
+
if (context.isPartial) {
|
|
113
|
+
return {
|
|
114
|
+
summary: "waiting for user input",
|
|
115
|
+
status: "running",
|
|
116
|
+
replaceCallOnPartial: true,
|
|
117
|
+
collapsedRows: [theme.fg("muted", "Waiting for user input...")],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (details === undefined)
|
|
121
|
+
return { summary: "", status: "ok" };
|
|
122
|
+
if (isBatchDetails(details))
|
|
123
|
+
return batchResult(details, theme);
|
|
124
|
+
return singleResult(details, theme);
|
|
125
|
+
}
|
|
126
|
+
/** Native renderers plus the presentation provider ext-ui decorates when installed. */
|
|
127
|
+
export function createAskUserRenderers(pi) {
|
|
128
|
+
return createToolPresentationRenderers(pi, "ask_user", presentAskUser);
|
|
129
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type KeyId, Markdown } from "@earendil-works/pi-tui";
|
|
3
|
+
/**
|
|
4
|
+
* Owns the question's context block: the component that renders it, whether it is
|
|
5
|
+
* long enough to collapse, and the expand/collapse state the user toggles with the
|
|
6
|
+
* context shortcut. The prompt only asks for display lines; the pane never decides
|
|
7
|
+
* layout, scrolling, or which mode is active.
|
|
8
|
+
*/
|
|
9
|
+
export declare class PromptContextPane {
|
|
10
|
+
readonly component: Markdown | undefined;
|
|
11
|
+
private readonly text;
|
|
12
|
+
private readonly theme;
|
|
13
|
+
private readonly onChange;
|
|
14
|
+
private collapsibleState;
|
|
15
|
+
private expandedState;
|
|
16
|
+
constructor(text: string | undefined, theme: Theme, onChange: () => void);
|
|
17
|
+
get collapsible(): boolean;
|
|
18
|
+
get expanded(): boolean;
|
|
19
|
+
fullLines(width: number): string[];
|
|
20
|
+
displayLines(fullContextLines: string[], width: number): string[];
|
|
21
|
+
toggleKey(): KeyId;
|
|
22
|
+
/** Marks the block as long enough to collapse; a new state starts collapsed. */
|
|
23
|
+
setCollapsible(value: boolean): void;
|
|
24
|
+
toggle(): boolean;
|
|
25
|
+
updateText(): void;
|
|
26
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Markdown } from "@earendil-works/pi-tui";
|
|
3
|
+
import { fitRow } from "@hheei/pi-ext-core";
|
|
4
|
+
import { CONTEXT_TOGGLE_KEYS } from "./constants";
|
|
5
|
+
/**
|
|
6
|
+
* Owns the question's context block: the component that renders it, whether it is
|
|
7
|
+
* long enough to collapse, and the expand/collapse state the user toggles with the
|
|
8
|
+
* context shortcut. The prompt only asks for display lines; the pane never decides
|
|
9
|
+
* layout, scrolling, or which mode is active.
|
|
10
|
+
*/
|
|
11
|
+
export class PromptContextPane {
|
|
12
|
+
component;
|
|
13
|
+
text;
|
|
14
|
+
theme;
|
|
15
|
+
onChange;
|
|
16
|
+
collapsibleState = false;
|
|
17
|
+
expandedState = false;
|
|
18
|
+
constructor(text, theme, onChange) {
|
|
19
|
+
this.text = text;
|
|
20
|
+
this.theme = theme;
|
|
21
|
+
this.onChange = onChange;
|
|
22
|
+
if (!text) {
|
|
23
|
+
this.component = undefined;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
this.component = new Markdown("", 1, 0, getMarkdownTheme());
|
|
27
|
+
}
|
|
28
|
+
get collapsible() {
|
|
29
|
+
return this.collapsibleState;
|
|
30
|
+
}
|
|
31
|
+
get expanded() {
|
|
32
|
+
return this.expandedState;
|
|
33
|
+
}
|
|
34
|
+
fullLines(width) {
|
|
35
|
+
return this.component ? this.component.render(width) : [];
|
|
36
|
+
}
|
|
37
|
+
displayLines(fullContextLines, width) {
|
|
38
|
+
if (fullContextLines.length === 0)
|
|
39
|
+
return [];
|
|
40
|
+
if (!this.collapsibleState || this.expandedState)
|
|
41
|
+
return fullContextLines;
|
|
42
|
+
const label = `Context (${fullContextLines.length} lines) — ${this.toggleKey()} expand`;
|
|
43
|
+
return [fitRow(this.theme.fg("dim", label), width, "")];
|
|
44
|
+
}
|
|
45
|
+
toggleKey() {
|
|
46
|
+
return CONTEXT_TOGGLE_KEYS[0];
|
|
47
|
+
}
|
|
48
|
+
/** Marks the block as long enough to collapse; a new state starts collapsed. */
|
|
49
|
+
setCollapsible(value) {
|
|
50
|
+
if (this.collapsibleState === value)
|
|
51
|
+
return;
|
|
52
|
+
this.collapsibleState = value;
|
|
53
|
+
this.expandedState = false;
|
|
54
|
+
this.onChange();
|
|
55
|
+
}
|
|
56
|
+
toggle() {
|
|
57
|
+
if (!this.collapsibleState)
|
|
58
|
+
return false;
|
|
59
|
+
this.expandedState = !this.expandedState;
|
|
60
|
+
this.onChange();
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
updateText() {
|
|
64
|
+
if (!this.component || !this.text)
|
|
65
|
+
return;
|
|
66
|
+
this.component.setText(`**Context:**\n${this.text}`);
|
|
67
|
+
}
|
|
68
|
+
}
|