pi-pier 0.2.7 → 0.3.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/README.md +26 -12
- package/package.json +8 -3
- package/schemas/efficiency-config.schema.json +77 -0
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +11 -0
- package/src/ansi-text.ts +134 -0
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +139 -88
- package/src/bootstrap.ts +13 -14
- package/src/compact-coordinator.ts +471 -0
- package/src/compact-economics-core.ts +328 -0
- package/src/config-catalog-core.ts +332 -0
- package/src/config-command.ts +388 -0
- package/src/dashboard-command.ts +108 -0
- package/src/efficiency-config-core.ts +464 -0
- package/src/efficiency-store.ts +299 -0
- package/src/focus-poller.ts +223 -0
- package/src/gc-core.ts +77 -18
- package/src/herdr-client.ts +284 -154
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +29 -16
- package/src/index-master.ts +29 -19
- package/src/index-notices.ts +35 -11
- package/src/index-pipe.ts +17 -7
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +7 -9
- package/src/index.ts +425 -388
- package/src/jev-client.ts +141 -0
- package/src/jev-core.ts +431 -0
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +98 -37
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +200 -0
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +100 -0
- package/src/pipe-channel.ts +65 -52
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +12 -12
- package/src/plugins/heat-plan.ts +158 -0
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +237 -123
- package/src/{core → plugins}/todo.ts +75 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +322 -0
- package/src/reducer-invoker.ts +419 -0
- package/src/renderers.ts +187 -0
- package/src/role-loader.ts +35 -29
- package/src/role-manifest.ts +23 -32
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -43
- package/src/session-tail.ts +102 -23
- package/src/settle-wake-core.ts +55 -22
- package/src/slim-frame.ts +29 -124
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +24 -30
- package/src/subagent-core.ts +256 -119
- package/src/subagent-gc.ts +131 -69
- package/src/subagent-poller.ts +401 -12
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +651 -28
- package/src/swallow.ts +65 -0
- package/src/terminal-core.ts +160 -69
- package/src/todo-core.ts +62 -113
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +30 -48
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +27 -35
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +55 -25
- package/src/core/subagent.ts +0 -839
- package/src/git-adapter.ts +0 -104
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -110
- package/src/subagent-poll-loop.ts +0 -285
- package/src/subagent-port.ts +0 -27
- package/src/subagent-scope.ts +0 -39
- package/src/subagent-session-io.ts +0 -147
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ask_user_question dialog: one pier-owned selector for single- and multi-select questions, rendered
|
|
3
|
+
* through `ctx.ui.custom`. Both modes share it so the chrome — border rules, accent+bold title,
|
|
4
|
+
* `→ ` cursor, dim descriptions, keyHint footer — cannot diverge; only the checkbox column, the
|
|
5
|
+
* hints and the selection counter are mode-specific.
|
|
6
|
+
*/
|
|
7
|
+
import { styledWidth, truncateStyled, wrapStyled } from './ansi-text.ts';
|
|
8
|
+
|
|
9
|
+
/** Label of the trailing free-text row; ask-user reuses it for the typed fallback list. */
|
|
10
|
+
export const OTHER_ROW_LABEL = 'Other (type your own)';
|
|
11
|
+
|
|
12
|
+
/** Marker the UI appends to the recommended row; the typed fallback list uses the same one. */
|
|
13
|
+
export const RECOMMENDED_SUFFIX = ' (Recommended)';
|
|
14
|
+
|
|
15
|
+
interface DialogOption {
|
|
16
|
+
label: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DialogConfig {
|
|
21
|
+
options: readonly DialogOption[];
|
|
22
|
+
allowOther: boolean;
|
|
23
|
+
/** 0-based recommended option: the cursor starts on it, and multi mode also starts with it
|
|
24
|
+
* checked so a bare enter agrees with single mode in both modes. */
|
|
25
|
+
recommended?: number;
|
|
26
|
+
/** true = toggle list (space toggles, enter confirms the set); false = pick-one (enter/space takes the cursor row). */
|
|
27
|
+
multi: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DialogState {
|
|
31
|
+
/** Cursor row: 0..options.length-1 are choices, options.length is the Other row. */
|
|
32
|
+
cursor: number;
|
|
33
|
+
/** Toggle state per authored option (never includes the Other row). */
|
|
34
|
+
selected: readonly boolean[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type DialogKey = 'up' | 'down' | 'toggle' | 'confirm' | 'cancel' | 'all' | 'ignore';
|
|
38
|
+
|
|
39
|
+
export type DialogOutcome =
|
|
40
|
+
| { kind: 'selected'; labels: string[] }
|
|
41
|
+
| { kind: 'other' }
|
|
42
|
+
| { kind: 'cancel' };
|
|
43
|
+
|
|
44
|
+
export interface DialogStep {
|
|
45
|
+
state: DialogState;
|
|
46
|
+
outcome?: DialogOutcome;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createDialogState(config: DialogConfig): DialogState {
|
|
50
|
+
const hasRecommended =
|
|
51
|
+
config.recommended !== undefined
|
|
52
|
+
&& config.recommended >= 0
|
|
53
|
+
&& config.recommended < config.options.length;
|
|
54
|
+
const start = hasRecommended ? config.recommended! : 0;
|
|
55
|
+
return {
|
|
56
|
+
cursor: start,
|
|
57
|
+
selected: config.options.map((_, i) => config.multi && hasRecommended && i === start),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function rowCount(config: DialogConfig): number {
|
|
62
|
+
return config.options.length + (config.allowOther ? 1 : 0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isOtherRow(state: DialogState, config: DialogConfig): boolean {
|
|
66
|
+
return config.allowOther && state.cursor === config.options.length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function selectedLabels(state: DialogState, config: DialogConfig): string[] {
|
|
70
|
+
const labels: string[] = [];
|
|
71
|
+
config.options.forEach((option, i) => {
|
|
72
|
+
if (state.selected[i]) labels.push(option.label);
|
|
73
|
+
});
|
|
74
|
+
return labels;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Apply one key. A returned outcome ends the interaction. */
|
|
78
|
+
export function stepDialog(state: DialogState, key: DialogKey, config: DialogConfig): DialogStep {
|
|
79
|
+
const rows = rowCount(config);
|
|
80
|
+
if (rows === 0) return key === 'cancel' ? { state, outcome: { kind: 'cancel' } } : { state };
|
|
81
|
+
switch (key) {
|
|
82
|
+
case 'cancel':
|
|
83
|
+
return { state, outcome: { kind: 'cancel' } };
|
|
84
|
+
case 'up':
|
|
85
|
+
case 'down': {
|
|
86
|
+
const delta = key === 'up' ? -1 : 1;
|
|
87
|
+
const cursor = (state.cursor + delta + rows) % rows;
|
|
88
|
+
return { state: { ...state, cursor } };
|
|
89
|
+
}
|
|
90
|
+
case 'all': {
|
|
91
|
+
if (!config.multi) return { state };
|
|
92
|
+
const anyUnselected = state.selected.some((v) => !v);
|
|
93
|
+
return { state: { ...state, selected: state.selected.map(() => anyUnselected) } };
|
|
94
|
+
}
|
|
95
|
+
case 'toggle':
|
|
96
|
+
case 'confirm': {
|
|
97
|
+
if (isOtherRow(state, config)) return { state, outcome: { kind: 'other' } };
|
|
98
|
+
if (!config.multi) {
|
|
99
|
+
// Pick-one: enter and space both take the cursor row.
|
|
100
|
+
return { state, outcome: { kind: 'selected', labels: [config.options[state.cursor]!.label] } };
|
|
101
|
+
}
|
|
102
|
+
if (key === 'toggle') {
|
|
103
|
+
const selected = state.selected.map((v, i) => (i === state.cursor ? !v : v));
|
|
104
|
+
return { state: { ...state, selected } };
|
|
105
|
+
}
|
|
106
|
+
// Enter on a choice row confirms the current toggles; a single-choice
|
|
107
|
+
// question needs no separate space press.
|
|
108
|
+
if (config.options.length === 1) {
|
|
109
|
+
return { state, outcome: { kind: 'selected', labels: [config.options[0]!.label] } };
|
|
110
|
+
}
|
|
111
|
+
return { state, outcome: { kind: 'selected', labels: selectedLabels(state, config) } };
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
return { state };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The slice of pi's KeybindingsManager custom factories receive as 3rd arg. */
|
|
119
|
+
export interface KeybindingsLike {
|
|
120
|
+
matches(data: string, keybinding: string): boolean;
|
|
121
|
+
getKeys(keybinding: string): string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Key resolution, in order (first match wins). `binding` is the host action id consulted when the
|
|
125
|
+
* factory received a keybindings manager; `sequences` are the raw fallbacks used when it did not
|
|
126
|
+
* (older hosts, tests). Mirrors pi's defaults, plus j/k for vim keys; `toggle` has no pi action. */
|
|
127
|
+
const KEY_RESOLUTION: ReadonlyArray<{ key: DialogKey; binding?: string; sequences: readonly string[] }> = [
|
|
128
|
+
{ key: 'up', binding: 'tui.select.up', sequences: ['\x1b[A', '\x1bOA', 'k'] },
|
|
129
|
+
{ key: 'down', binding: 'tui.select.down', sequences: ['\x1b[B', '\x1bOB', 'j'] },
|
|
130
|
+
{ key: 'toggle', sequences: [' '] },
|
|
131
|
+
{ key: 'confirm', binding: 'tui.select.confirm', sequences: ['\r', '\n'] },
|
|
132
|
+
{ key: 'cancel', binding: 'tui.select.cancel', sequences: ['\x1b', '\x03'] },
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
export function resolveDialogKey(data: string, keybindings?: KeybindingsLike): DialogKey {
|
|
136
|
+
for (const entry of KEY_RESOLUTION) {
|
|
137
|
+
if (entry.binding !== undefined && keybindings?.matches?.(data, entry.binding) === true) return entry.key;
|
|
138
|
+
if (entry.sequences.includes(data)) return entry.key;
|
|
139
|
+
}
|
|
140
|
+
return data === 'a' || data === 'A' ? 'all' : 'ignore';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface MinimalTheme {
|
|
144
|
+
fg(color: string, text: string): string;
|
|
145
|
+
bold(text: string): string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface DialogComponent {
|
|
149
|
+
render(width: number): string[];
|
|
150
|
+
handleInput(data: string): void;
|
|
151
|
+
invalidate(): void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface DialogUiOptions {
|
|
155
|
+
title: string;
|
|
156
|
+
config: DialogConfig;
|
|
157
|
+
theme: MinimalTheme;
|
|
158
|
+
keybindings?: KeybindingsLike;
|
|
159
|
+
/** Aborting cancels the dialog so the ask tool result and herdr gate settle. */
|
|
160
|
+
signal?: AbortSignal;
|
|
161
|
+
/** pi's ctrl+o tool-output expansion, when the host exposes it. */
|
|
162
|
+
toggleToolsExpanded?: () => void;
|
|
163
|
+
done(outcome: DialogOutcome): void;
|
|
164
|
+
requestRender?: () => void;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Numbered line for the typed-prompt fallback list (`1. Label (Recommended) — description`); the dialog
|
|
168
|
+
* itself renders unnumbered rows. */
|
|
169
|
+
export function numberedOptionLine(option: DialogOption, index: number, recommended?: number): string {
|
|
170
|
+
const marker = recommended === index ? RECOMMENDED_SUFFIX : '';
|
|
171
|
+
const description = option.description ? ` — ${option.description}` : '';
|
|
172
|
+
return `${index + 1}. ${option.label}${marker}${description}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function dialogLines(state: DialogState, config: DialogConfig, theme: MinimalTheme): string[] {
|
|
176
|
+
const lines: string[] = [];
|
|
177
|
+
config.options.forEach((option, i) => {
|
|
178
|
+
const onCursor = i === state.cursor;
|
|
179
|
+
const cursor = onCursor ? theme.fg('accent', '→ ') : ' ';
|
|
180
|
+
const label = `${option.label}${config.recommended === i ? RECOMMENDED_SUFFIX : ''}`;
|
|
181
|
+
const desc = option.description ? theme.fg('dim', ` — ${option.description}`) : '';
|
|
182
|
+
if (config.multi) {
|
|
183
|
+
const box = state.selected[i] ? theme.fg('success', '[x]') : '[ ]';
|
|
184
|
+
const styled = state.selected[i] ? theme.bold(label) : label;
|
|
185
|
+
lines.push(`${cursor}${box} ${styled}${desc}`);
|
|
186
|
+
} else {
|
|
187
|
+
// Pick-one highlights the whole row — enter takes exactly what is lit.
|
|
188
|
+
const styled = onCursor ? theme.fg('accent', label) : label;
|
|
189
|
+
lines.push(`${cursor}${styled}${desc}`);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
if (config.allowOther) {
|
|
193
|
+
const cursor = isOtherRow(state, config) ? theme.fg('accent', '→ ') : ' ';
|
|
194
|
+
const row = config.multi ? `[ ] ${OTHER_ROW_LABEL}` : OTHER_ROW_LABEL;
|
|
195
|
+
lines.push(`${cursor}${theme.fg('dim', row)}`);
|
|
196
|
+
}
|
|
197
|
+
return lines;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** keyHint style shared with pi's selector: dim key + muted verb. */
|
|
201
|
+
function hint(theme: MinimalTheme, key: string, verb: string): string {
|
|
202
|
+
return theme.fg('dim', key) + theme.fg('muted', ` ${verb}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function displayKeys(keybindings: KeybindingsLike | undefined, id: 'confirm' | 'cancel', fallback: string): string {
|
|
206
|
+
const keys = keybindings?.getKeys?.(`tui.select.${id}`);
|
|
207
|
+
return keys && keys.length > 0 ? keys.join('/') : fallback;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function dialogFooter(
|
|
211
|
+
state: DialogState,
|
|
212
|
+
config: DialogConfig,
|
|
213
|
+
theme: MinimalTheme,
|
|
214
|
+
keybindings?: KeybindingsLike,
|
|
215
|
+
width?: number,
|
|
216
|
+
): string {
|
|
217
|
+
const confirm = displayKeys(keybindings, 'confirm', 'enter');
|
|
218
|
+
const cancel = displayKeys(keybindings, 'cancel', 'escape/ctrl+c');
|
|
219
|
+
const picked = selectedLabels(state, config).length;
|
|
220
|
+
const chip = config.multi && picked > 0 ? theme.fg('success', ` · ${picked} selected`) : '';
|
|
221
|
+
const navigate = hint(theme, '↑↓', 'navigate');
|
|
222
|
+
const toggle = hint(theme, 'space', 'toggle');
|
|
223
|
+
const all = hint(theme, 'a', 'all');
|
|
224
|
+
const action = hint(theme, confirm, config.multi ? 'confirm' : 'select');
|
|
225
|
+
const cancelHint = hint(theme, cancel, 'cancel');
|
|
226
|
+
const parts = config.multi ? [navigate, toggle, all, action, cancelHint] : [navigate, action, cancelHint];
|
|
227
|
+
const fits = (segs: string[]): boolean => styledWidth(segs.join(' ') + chip) <= (width ?? Infinity);
|
|
228
|
+
// Narrow panes shed the most guessable extras first; the action keys and the live count are the floor.
|
|
229
|
+
for (const droppable of config.multi ? [all, navigate, cancelHint] : [navigate]) {
|
|
230
|
+
if (fits(parts)) break;
|
|
231
|
+
parts.splice(parts.indexOf(droppable), 1);
|
|
232
|
+
}
|
|
233
|
+
return parts.join(' ') + chip;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function createDialogComponent(opts: DialogUiOptions): DialogComponent {
|
|
237
|
+
let state = createDialogState(opts.config);
|
|
238
|
+
let finished = false;
|
|
239
|
+
const onAbort = (): void => finish({ kind: 'cancel' });
|
|
240
|
+
function finish(outcome: DialogOutcome): void {
|
|
241
|
+
if (finished) return;
|
|
242
|
+
finished = true;
|
|
243
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
244
|
+
opts.done(outcome);
|
|
245
|
+
}
|
|
246
|
+
if (opts.signal) opts.signal.addEventListener('abort', onAbort, { once: true });
|
|
247
|
+
return {
|
|
248
|
+
render(width: number): string[] {
|
|
249
|
+
const border = opts.theme.fg('border', '─'.repeat(Math.max(1, width)));
|
|
250
|
+
// Content wraps inside a 1-col margin each side (like pi's Text(text, 1, 0)) instead of clipping.
|
|
251
|
+
const inner = Math.max(1, width - 2);
|
|
252
|
+
const pad = (line: string): string => ` ${line}`;
|
|
253
|
+
return [
|
|
254
|
+
border,
|
|
255
|
+
'',
|
|
256
|
+
...wrapStyled(opts.theme.fg('accent', opts.theme.bold(opts.title)), inner).map(pad),
|
|
257
|
+
'',
|
|
258
|
+
...dialogLines(state, opts.config, opts.theme)
|
|
259
|
+
.flatMap((row) => wrapStyled(row, inner))
|
|
260
|
+
.map(pad),
|
|
261
|
+
'',
|
|
262
|
+
truncateStyled(` ${dialogFooter(state, opts.config, opts.theme, opts.keybindings, inner)}`, width),
|
|
263
|
+
'',
|
|
264
|
+
border,
|
|
265
|
+
];
|
|
266
|
+
},
|
|
267
|
+
handleInput(data: string): void {
|
|
268
|
+
if (finished) return;
|
|
269
|
+
if (opts.keybindings?.matches?.(data, 'app.tools.expand') === true) {
|
|
270
|
+
opts.toggleToolsExpanded?.();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const key = resolveDialogKey(data, opts.keybindings);
|
|
274
|
+
if (key === 'ignore') return;
|
|
275
|
+
const next = stepDialog(state, key, opts.config);
|
|
276
|
+
state = next.state;
|
|
277
|
+
opts.requestRender?.();
|
|
278
|
+
if (next.outcome) finish(next.outcome);
|
|
279
|
+
},
|
|
280
|
+
invalidate(): void { /* No cached lines. */ },
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Run the dialog through `ctx.ui.custom`. Returns null when the host cannot render custom components
|
|
285
|
+
* (RPC mode / older pi), so the caller can fall back to the select dialog or the typed prompt. */
|
|
286
|
+
export async function runSelectDialog(
|
|
287
|
+
custom: (factory: unknown) => Promise<unknown>,
|
|
288
|
+
title: string,
|
|
289
|
+
config: DialogConfig,
|
|
290
|
+
options?: { signal?: AbortSignal; toggleToolsExpanded?: () => void },
|
|
291
|
+
): Promise<DialogOutcome | null> {
|
|
292
|
+
if (options?.signal?.aborted) return { kind: 'cancel' };
|
|
293
|
+
const result = await custom((tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) =>
|
|
294
|
+
createDialogComponent({
|
|
295
|
+
title,
|
|
296
|
+
config,
|
|
297
|
+
theme: theme as MinimalTheme,
|
|
298
|
+
keybindings: keybindings as KeybindingsLike | undefined,
|
|
299
|
+
signal: options?.signal,
|
|
300
|
+
toggleToolsExpanded: options?.toggleToolsExpanded,
|
|
301
|
+
done: (outcome) => done(outcome),
|
|
302
|
+
requestRender: () => (tui as { requestRender?: () => void } | undefined)?.requestRender?.(),
|
|
303
|
+
}));
|
|
304
|
+
if (result === undefined || result === null) return null;
|
|
305
|
+
const outcome = result as DialogOutcome;
|
|
306
|
+
if (outcome.kind === 'cancel' || outcome.kind === 'other' || outcome.kind === 'selected') return outcome;
|
|
307
|
+
return null;
|
|
308
|
+
}
|
package/src/ask-user.ts
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ask_user_question I/O: normalize params, select+Other dialog, result envelope.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* This module is the questionnaire — authored options plus a runtime Other row —
|
|
6
|
-
* so the model cannot forget a free-text escape and Esc is a real decline.
|
|
2
|
+
* ask_user_question I/O: normalize params, select+Other dialog, result envelope. The herdr gate
|
|
3
|
+
* (blocked / pi-ask / widget collapse) stays in index.ts; this module owns the questionnaire — authored
|
|
4
|
+
* options plus a runtime Other row, so the model cannot forget a free-text escape and Esc is a real decline.
|
|
7
5
|
*/
|
|
8
6
|
import { Type } from 'typebox';
|
|
7
|
+
import { OTHER_ROW_LABEL, numberedOptionLine, runSelectDialog } from './ask-dialog.ts';
|
|
9
8
|
|
|
10
9
|
export const ASK_TOOL_NAME = 'ask_user_question';
|
|
11
|
-
export const OTHER_OPTION =
|
|
12
|
-
export const OTHER_ANSWER = 'Other';
|
|
13
|
-
export const RECOMMENDED_SUFFIX = ' (Recommended)';
|
|
14
|
-
export const MIN_OPTIONS = 2;
|
|
15
|
-
export const MAX_OPTIONS = 5;
|
|
16
|
-
export const MAX_QUESTIONS = 4;
|
|
17
|
-
export const MAX_LABEL_CHARS = 60;
|
|
10
|
+
export const OTHER_OPTION = OTHER_ROW_LABEL;
|
|
18
11
|
|
|
19
12
|
export const DECLINE_TEXT =
|
|
20
13
|
'User declined to answer the questions. Continue with your best judgment, or ask different questions.';
|
|
21
14
|
export const NO_UI_TEXT =
|
|
22
15
|
'Error: UI not available (running in non-interactive mode). The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, without using this tool.';
|
|
23
16
|
|
|
17
|
+
/** Answer kind reported for the free-text row (the questions themselves are the detail). */
|
|
18
|
+
const OTHER_ANSWER = 'Other';
|
|
19
|
+
const MIN_OPTIONS = 2;
|
|
20
|
+
const MAX_OPTIONS = 5;
|
|
21
|
+
const MAX_QUESTIONS = 4;
|
|
22
|
+
const MAX_LABEL_CHARS = 60;
|
|
23
|
+
|
|
24
24
|
const CUSTOM_ANSWER_TITLE = 'Type your answer:';
|
|
25
25
|
const MULTI_INSTRUCTIONS =
|
|
26
26
|
'Enter the numbers of all that apply, comma-separated (e.g. "1,3"), or type a custom answer as plain text.';
|
|
27
|
+
const MULTI_NO_OTHER_INSTRUCTIONS =
|
|
28
|
+
'Enter the numbers of all that apply, comma-separated (e.g. "1,3"). There is no free-text answer for this question.';
|
|
27
29
|
const NUMBERED_HINT = 'Enter a number, or type a custom answer.';
|
|
28
30
|
|
|
29
31
|
const RESERVED_LOWER: Record<string, true> = {
|
|
@@ -44,16 +46,18 @@ export const ASK_PARAMETERS = Type.Object({
|
|
|
44
46
|
options: Type.Optional(Type.Array(OptionSchema, {
|
|
45
47
|
description: '2-5 authored choices. Do NOT include Other; the UI appends it.',
|
|
46
48
|
})),
|
|
47
|
-
multi: Type.Optional(Type.Boolean({ description: 'Allow multiple selections (default false)' })),
|
|
49
|
+
multi: Type.Optional(Type.Boolean({ description: 'Allow multiple selections (default false). In the TUI this opens an interactive toggle list (space toggles, enter confirms, esc declines).' })),
|
|
50
|
+
allowOther: Type.Optional(Type.Boolean({ description: 'Set false to offer the authored choices only, with no free-text row (default true).' })),
|
|
48
51
|
recommended: Type.Optional(Type.Number({ description: '0-based index of the recommended option; UI adds (Recommended)' })),
|
|
49
52
|
questions: Type.Optional(Type.Array(
|
|
50
53
|
Type.Object({
|
|
51
54
|
id: Type.Optional(Type.String({ description: 'Stable id for mapping answers' })),
|
|
52
55
|
question: Type.String({ description: 'Question text' }),
|
|
53
56
|
options: Type.Array(OptionSchema, {
|
|
54
|
-
description: '2-5 authored choices. Do NOT include Other; the UI appends it.',
|
|
57
|
+
description: '2-5 authored choices. Do NOT include Other; the UI appends it unless allowOther is false.',
|
|
55
58
|
}),
|
|
56
|
-
multi: Type.Optional(Type.Boolean({ description: 'Allow multiple selections (default false)' })),
|
|
59
|
+
multi: Type.Optional(Type.Boolean({ description: 'Allow multiple selections (default false). In the TUI this opens an interactive toggle list (space toggles, enter confirms).' })),
|
|
60
|
+
allowOther: Type.Optional(Type.Boolean({ description: 'Set false to offer the authored choices only, with no free-text row (default true).' })),
|
|
57
61
|
recommended: Type.Optional(Type.Number({ description: '0-based recommended option index' })),
|
|
58
62
|
}),
|
|
59
63
|
{ description: '1-4 related questions in one call. Wins over top-level question/options when set.' },
|
|
@@ -66,14 +70,15 @@ export const ASK_TOOL_DESCRIPTION = [
|
|
|
66
70
|
'While waiting, the pane shows as blocked in herdr (the human sees it and can step in).',
|
|
67
71
|
'Prefer 2-5 options with short labels; put tradeoffs in description.',
|
|
68
72
|
'Do NOT include an "Other" option — the UI appends "Other (type your own)" automatically.',
|
|
69
|
-
'Use recommended (0-based) to mark the default; "(Recommended)" is added automatically.',
|
|
70
|
-
'Use multi: true when several options can apply
|
|
71
|
-
'
|
|
73
|
+
'Use recommended (0-based) to mark the default; "(Recommended)" is added automatically — do NOT write it in the label.',
|
|
74
|
+
'Use multi: true when several options can apply — the TUI opens an interactive toggle list (space toggles a row, a toggles all, enter confirms, esc declines) instead of making the human type numbers.',
|
|
75
|
+
'Set allowOther: false when the authored choices are exhaustive and a free-text answer would be misleading.',
|
|
72
76
|
].join(' ');
|
|
73
77
|
|
|
74
78
|
export const ASK_PROMPT_GUIDELINES = [
|
|
75
79
|
'Default to action. Ask only when options have materially different tradeoffs the user must decide.',
|
|
76
|
-
'Do NOT include "Other" in options; the UI appends it.',
|
|
80
|
+
'Do NOT include "Other" in options; the UI appends it unless allowOther is false. Group related questions in questions (max 4) rather than calling this tool repeatedly.',
|
|
81
|
+
'For multi questions the human toggles rows in a list and confirms with enter — never instruct them to type indices.',
|
|
77
82
|
'Short option labels; explanatory tradeoffs in description.',
|
|
78
83
|
];
|
|
79
84
|
|
|
@@ -83,6 +88,8 @@ export type AskQuestion = {
|
|
|
83
88
|
question: string;
|
|
84
89
|
options: AskOption[];
|
|
85
90
|
multi: boolean;
|
|
91
|
+
/** When false the UI offers the authored choices only (no free-text row). */
|
|
92
|
+
allowOther: boolean;
|
|
86
93
|
recommended?: number;
|
|
87
94
|
};
|
|
88
95
|
export type AskSpec =
|
|
@@ -112,6 +119,11 @@ export type AskToolResult = {
|
|
|
112
119
|
export type AskUi = {
|
|
113
120
|
select?: (title: string, options: string[], opts?: { signal?: AbortSignal }) => Promise<string | undefined>;
|
|
114
121
|
input: (title: string, placeholder?: string, opts?: { signal?: AbortSignal }) => Promise<string | undefined>;
|
|
122
|
+
/** TUI-only: full custom components (absent in RPC mode, where it returns undefined). */
|
|
123
|
+
custom?: (factory: unknown) => Promise<unknown>;
|
|
124
|
+
/** TUI-only helpers from pi's extension UI context; wired for ctrl+o parity with pi's selector. */
|
|
125
|
+
getToolsExpanded?: () => boolean;
|
|
126
|
+
setToolsExpanded?: (expanded: boolean) => void;
|
|
115
127
|
};
|
|
116
128
|
|
|
117
129
|
export type PrepareAskResult =
|
|
@@ -169,8 +181,17 @@ function parseOptions(raw: unknown): { ok: true; options: AskOption[] } | { ok:
|
|
|
169
181
|
const seen = new Set<string>();
|
|
170
182
|
for (const item of raw) {
|
|
171
183
|
const rec = asRecord(item);
|
|
172
|
-
const
|
|
173
|
-
|
|
184
|
+
const rawLabel = typeof rec?.label === 'string' ? rec.label.trim() : '';
|
|
185
|
+
// B3: models repeat the marker the UI appends ("Redis (Recommended)"); strip only a trailing one.
|
|
186
|
+
const label = rawLabel.replace(/\s*[([]\s*recommended\s*[)\]]\s*$/i, '').trim();
|
|
187
|
+
if (!label) {
|
|
188
|
+
return fail(
|
|
189
|
+
rawLabel
|
|
190
|
+
? `Error: option label "${rawLabel}" is only the marker the UI appends — give the choice a real name and pass "recommended" instead`
|
|
191
|
+
: 'Error: each option needs a non-empty label',
|
|
192
|
+
'empty_options',
|
|
193
|
+
);
|
|
194
|
+
}
|
|
174
195
|
if (label.length > MAX_LABEL_CHARS) {
|
|
175
196
|
return fail(`Error: option label exceeds ${MAX_LABEL_CHARS} characters`, 'label_too_long');
|
|
176
197
|
}
|
|
@@ -205,6 +226,7 @@ function parseQuestion(raw: unknown): { ok: true; question: AskQuestion } | { ok
|
|
|
205
226
|
question,
|
|
206
227
|
options: parsed.options,
|
|
207
228
|
multi: rec.multi === true,
|
|
229
|
+
allowOther: rec.allowOther !== false,
|
|
208
230
|
...(id ? { id } : {}),
|
|
209
231
|
...(recommended !== undefined ? { recommended } : {}),
|
|
210
232
|
},
|
|
@@ -255,6 +277,7 @@ export function prepareAsk(params: unknown): PrepareAskResult {
|
|
|
255
277
|
question,
|
|
256
278
|
options: parsed.options,
|
|
257
279
|
multi: rec.multi === true,
|
|
280
|
+
allowOther: rec.allowOther !== false,
|
|
258
281
|
...(recommended !== undefined ? { recommended } : {}),
|
|
259
282
|
}],
|
|
260
283
|
},
|
|
@@ -268,15 +291,10 @@ export function gateLabel(spec: AskSpec): string {
|
|
|
268
291
|
return extra > 0 ? `${first} (+${extra})` : first;
|
|
269
292
|
}
|
|
270
293
|
|
|
271
|
-
|
|
272
|
-
const rec = recommended === index ? RECOMMENDED_SUFFIX : '';
|
|
273
|
-
const desc = option.description ? ` — ${option.description}` : '';
|
|
274
|
-
return `${index + 1}. ${option.label}${rec}${desc}`;
|
|
275
|
-
}
|
|
276
|
-
|
|
294
|
+
/** Typed-prompt list: numbered authored options plus the Other row when it is offered. */
|
|
277
295
|
export function optionLines(question: AskQuestion): string[] {
|
|
278
|
-
const lines = question.options.map((option, i) =>
|
|
279
|
-
lines.push(`${question.options.length + 1}. ${OTHER_OPTION}`);
|
|
296
|
+
const lines = question.options.map((option, i) => numberedOptionLine(option, i, question.recommended));
|
|
297
|
+
if (question.allowOther) lines.push(`${question.options.length + 1}. ${OTHER_OPTION}`);
|
|
280
298
|
return lines;
|
|
281
299
|
}
|
|
282
300
|
|
|
@@ -294,63 +312,55 @@ function withId(question: AskQuestion, answer: AskAnswer): AskAnswer {
|
|
|
294
312
|
return question.id ? { ...answer, id: question.id } : answer;
|
|
295
313
|
}
|
|
296
314
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
:
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (idx != null && idx < question.options.length) {
|
|
308
|
-
return withId(question, {
|
|
309
|
-
question: question.question,
|
|
310
|
-
kind: 'option',
|
|
311
|
-
answer: question.options[idx]!.label,
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
if (idx === question.options.length) {
|
|
315
|
-
const typed = await ui.input(`${question.question}\n\n${CUSTOM_ANSWER_TITLE}`, '', opts);
|
|
316
|
-
if (typed == null || !typed.trim()) return undefined;
|
|
317
|
-
return withId(question, {
|
|
318
|
-
question: question.question,
|
|
319
|
-
kind: 'custom',
|
|
320
|
-
answer: OTHER_ANSWER,
|
|
321
|
-
customInput: typed.trim(),
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
if (!ui.select) {
|
|
325
|
-
return withId(question, {
|
|
326
|
-
question: question.question,
|
|
327
|
-
kind: 'custom',
|
|
328
|
-
answer: OTHER_ANSWER,
|
|
329
|
-
customInput: trimmed,
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
return undefined;
|
|
315
|
+
/** Free-text answer behind the Other row; shared by the dialog and the typed-prompt fallback. */
|
|
316
|
+
async function askOtherText(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
|
|
317
|
+
const typed = await ui.input(`${question.question}\n\n${CUSTOM_ANSWER_TITLE}`, '', signal ? { signal } : undefined);
|
|
318
|
+
if (typed == null || !typed.trim()) return undefined;
|
|
319
|
+
return withId(question, {
|
|
320
|
+
question: question.question,
|
|
321
|
+
kind: 'custom',
|
|
322
|
+
answer: OTHER_ANSWER,
|
|
323
|
+
customInput: typed.trim(),
|
|
324
|
+
});
|
|
333
325
|
}
|
|
334
326
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
327
|
+
/**
|
|
328
|
+
* Interpret one typed answer. Single-select: a listed line or its number takes that option, the Other
|
|
329
|
+
* number opens the free-text prompt, anything else is a custom answer — but only on the typed fallback,
|
|
330
|
+
* since a real select dialog can only return a listed row. Multi-select: comma/space separated numbers,
|
|
331
|
+
* the Other number alone opens free text, an empty submission is an empty selection. Returns 'other' to
|
|
332
|
+
* ask for free text, undefined to decline.
|
|
333
|
+
*/
|
|
334
|
+
function parseTypedPicks(
|
|
335
|
+
question: AskQuestion,
|
|
336
|
+
lines: readonly string[],
|
|
337
|
+
raw: string,
|
|
338
|
+
typedFallback: boolean,
|
|
339
|
+
): AskAnswer[] | 'other' | undefined {
|
|
340
|
+
const trimmed = raw.trim();
|
|
341
|
+
const otherIndex = question.options.length;
|
|
346
342
|
if (!trimmed) {
|
|
347
|
-
return
|
|
343
|
+
return question.multi
|
|
344
|
+
? [withId(question, { question: question.question, kind: 'multi', answer: null, selected: [] })]
|
|
345
|
+
: undefined;
|
|
346
|
+
}
|
|
347
|
+
if (!question.multi) {
|
|
348
|
+
const idx = parseChoice(trimmed, lines);
|
|
349
|
+
if (idx === otherIndex) return 'other';
|
|
350
|
+
if (idx != null && idx < otherIndex) {
|
|
351
|
+
return [withId(question, { question: question.question, kind: 'option', answer: question.options[idx]!.label })];
|
|
352
|
+
}
|
|
353
|
+
return typedFallback
|
|
354
|
+
? [withId(question, { question: question.question, kind: 'custom', answer: OTHER_ANSWER, customInput: trimmed })]
|
|
355
|
+
: undefined;
|
|
348
356
|
}
|
|
357
|
+
// The Other row is numbered like the authored options; typing it alone opens the free-text prompt.
|
|
358
|
+
if (trimmed.split(/[,\s]+/).length === 1 && parseChoice(trimmed, lines) === otherIndex) return 'other';
|
|
349
359
|
const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
|
|
350
360
|
const indices = tokens.map((tok) => {
|
|
351
361
|
if (!/^\d+\.?$/.test(tok)) return null;
|
|
352
362
|
const idx = Number.parseInt(tok, 10) - 1;
|
|
353
|
-
return idx >= 0 && idx <
|
|
363
|
+
return idx >= 0 && idx < otherIndex ? idx : null;
|
|
354
364
|
});
|
|
355
365
|
if (indices.every((i): i is number => i != null)) {
|
|
356
366
|
const selected: string[] = [];
|
|
@@ -358,14 +368,57 @@ async function askMulti(ui: AskUi, question: AskQuestion, signal?: AbortSignal):
|
|
|
358
368
|
const label = question.options[i]!.label;
|
|
359
369
|
if (!selected.includes(label)) selected.push(label);
|
|
360
370
|
}
|
|
361
|
-
return withId(question, { question: question.question, kind: 'multi', answer: null, selected });
|
|
371
|
+
return [withId(question, { question: question.question, kind: 'multi', answer: null, selected })];
|
|
362
372
|
}
|
|
363
|
-
return
|
|
364
|
-
question: question.question,
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
373
|
+
return question.allowOther
|
|
374
|
+
? [withId(question, { question: question.question, kind: 'custom', answer: OTHER_ANSWER, customInput: trimmed })]
|
|
375
|
+
: undefined;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Ask one question, both modes: the pier dialog, then the host's select dialog (single only), then
|
|
379
|
+
* the typed prompt. `ui.custom` is absent (or resolves undefined) in RPC mode, hence every rung. */
|
|
380
|
+
async function askQuestion(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
|
|
381
|
+
const { multi } = question;
|
|
382
|
+
if (typeof ui.custom === 'function') {
|
|
383
|
+
const toggleToolsExpanded = ui.getToolsExpanded && ui.setToolsExpanded
|
|
384
|
+
? (): void => ui.setToolsExpanded!(!ui.getToolsExpanded!())
|
|
385
|
+
: undefined;
|
|
386
|
+
const outcome = await runSelectDialog(
|
|
387
|
+
ui.custom,
|
|
388
|
+
question.question,
|
|
389
|
+
{
|
|
390
|
+
options: question.options,
|
|
391
|
+
allowOther: question.allowOther,
|
|
392
|
+
multi,
|
|
393
|
+
...(question.recommended !== undefined ? { recommended: question.recommended } : {}),
|
|
394
|
+
},
|
|
395
|
+
{ ...(signal ? { signal } : {}), ...(toggleToolsExpanded ? { toggleToolsExpanded } : {}) },
|
|
396
|
+
);
|
|
397
|
+
if (outcome?.kind === 'cancel') return undefined;
|
|
398
|
+
if (outcome?.kind === 'other') return askOtherText(ui, question, signal);
|
|
399
|
+
if (outcome?.kind === 'selected') {
|
|
400
|
+
return withId(
|
|
401
|
+
question,
|
|
402
|
+
multi
|
|
403
|
+
? { question: question.question, kind: 'multi', answer: null, selected: outcome.labels }
|
|
404
|
+
: { question: question.question, kind: 'option', answer: outcome.labels[0]! },
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const lines = optionLines(question);
|
|
409
|
+
const opts = signal ? { signal } : undefined;
|
|
410
|
+
// A multi question has no single-pick host dialog; its typed prompt is always the fallback.
|
|
411
|
+
const select = multi ? undefined : ui.select;
|
|
412
|
+
const hint = multi
|
|
413
|
+
? (question.allowOther ? MULTI_INSTRUCTIONS : MULTI_NO_OTHER_INSTRUCTIONS)
|
|
414
|
+
: NUMBERED_HINT;
|
|
415
|
+
const raw = select
|
|
416
|
+
? await select(question.question, lines, opts)
|
|
417
|
+
: await ui.input(`${question.question}\n\n${lines.join('\n')}\n\n${hint}`, multi ? '1,3' : '1', opts);
|
|
418
|
+
if (raw == null) return undefined;
|
|
419
|
+
const picks = parseTypedPicks(question, lines, raw, select === undefined);
|
|
420
|
+
if (picks === 'other') return askOtherText(ui, question, signal);
|
|
421
|
+
return picks?.[0];
|
|
369
422
|
}
|
|
370
423
|
|
|
371
424
|
export function formatAskContent(details: AskDetails): string {
|
|
@@ -398,9 +451,7 @@ export async function runAsk(spec: AskSpec, ui: AskUi, signal?: AbortSignal): Pr
|
|
|
398
451
|
}
|
|
399
452
|
const answers: AskAnswer[] = [];
|
|
400
453
|
for (const question of spec.questions) {
|
|
401
|
-
const answer = question
|
|
402
|
-
? await askMulti(ui, question, signal)
|
|
403
|
-
: await askSingle(ui, question, signal);
|
|
454
|
+
const answer = await askQuestion(ui, question, signal);
|
|
404
455
|
if (!answer) return okResult({ answers, cancelled: true });
|
|
405
456
|
answers.push(answer);
|
|
406
457
|
}
|