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.
Files changed (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. 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
- * Why: the herdr gate (blocked / pi-ask / widget collapse) stays in index.ts.
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 = 'Other (type your own)';
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. Group related questions in questions (max 4) rather than calling this tool repeatedly.',
71
- 'The answer comes back as the tool result; then continue your work.',
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 label = typeof rec?.label === 'string' ? rec.label.trim() : '';
173
- if (!label) return fail('Error: each option needs a non-empty label', 'empty_options');
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
- export function formatAuthoredLine(option: AskOption, index: number, recommended?: number): string {
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) => formatAuthoredLine(option, i, question.recommended));
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
- async function askSingle(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
298
- const lines = optionLines(question);
299
- const opts = signal ? { signal } : undefined;
300
- const chosen = ui.select
301
- ? await ui.select(question.question, lines, opts)
302
- : await ui.input(`${question.question}\n\n${lines.join('\n')}\n\n${NUMBERED_HINT}`, '1', opts);
303
- if (chosen == null) return undefined;
304
- const trimmed = chosen.trim();
305
- if (!trimmed) return undefined;
306
- const idx = parseChoice(trimmed, lines);
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
- async function askMulti(ui: AskUi, question: AskQuestion, signal?: AbortSignal): Promise<AskAnswer | undefined> {
336
- const list = question.options
337
- .map((option, i) => formatAuthoredLine(option, i, question.recommended))
338
- .join('\n');
339
- const value = await ui.input(
340
- `${question.question}\n\n${list}\n\n${MULTI_INSTRUCTIONS}`,
341
- '1,3',
342
- signal ? { signal } : undefined,
343
- );
344
- if (value == null) return undefined;
345
- const trimmed = value.trim();
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 withId(question, { question: question.question, kind: 'multi', answer: null, selected: [] });
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 < question.options.length ? idx : null;
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 withId(question, {
364
- question: question.question,
365
- kind: 'custom',
366
- answer: OTHER_ANSWER,
367
- customInput: trimmed,
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.multi
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
  }