@aeondave/pi-persona 1.17.1 → 1.17.3

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 (103) hide show
  1. package/AGENTS.md +59 -28
  2. package/CHANGELOG.md +234 -0
  3. package/README.md +16 -12
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +183 -55
  15. package/docs/EXPERIENCE.md +19 -9
  16. package/docs/MONITORS.md +62 -3
  17. package/docs/REFERENCE.md +82 -32
  18. package/docs/SPINE.md +2 -2
  19. package/docs/STRATEGIES.md +23 -8
  20. package/docs/TELEMETRY.md +1 -0
  21. package/flows/deep-review.flow.json +1 -1
  22. package/package.json +9 -8
  23. package/personas/audit.md +28 -17
  24. package/personas/dev.md +30 -32
  25. package/personas/elite.md +223 -221
  26. package/personas/judge.md +25 -19
  27. package/personas/magi.md +26 -29
  28. package/personas/planner.md +31 -24
  29. package/personas/researcher.md +25 -26
  30. package/personas/swarm.md +35 -25
  31. package/personas/verify.md +30 -19
  32. package/src/agents/agent.ts +20 -0
  33. package/src/bridge.ts +3 -2
  34. package/src/core/brief.ts +429 -356
  35. package/src/core/config.ts +15 -10
  36. package/src/core/model-catalog.ts +171 -0
  37. package/src/core/model-tier.ts +89 -0
  38. package/src/core/models.ts +13 -4
  39. package/src/core/monitor-output.ts +85 -0
  40. package/src/core/monitor.ts +34 -2
  41. package/src/core/nudge.ts +176 -23
  42. package/src/core/seed.ts +99 -6
  43. package/src/core/session-runs.ts +87 -0
  44. package/src/core/settings.ts +1 -1
  45. package/src/core/timer.ts +34 -9
  46. package/src/engine/adapter.ts +12 -8
  47. package/src/engine/async.ts +165 -146
  48. package/src/engine/child.ts +2 -2
  49. package/src/engine/host-module.ts +12 -0
  50. package/src/engine/inproc.ts +12 -7
  51. package/src/engine/stream.ts +4 -20
  52. package/src/exocom/install.ts +2 -1
  53. package/src/extension/engine.ts +43 -16
  54. package/src/extension/hooks.ts +106 -36
  55. package/src/extension/persona-model-settings.ts +31 -13
  56. package/src/extension/shared.ts +15 -7
  57. package/src/extension.ts +505 -241
  58. package/src/monitor/archive.ts +235 -0
  59. package/src/monitor/session.ts +21 -1
  60. package/src/orchestration/flow-journal.ts +26 -5
  61. package/src/orchestration/flow-run.ts +38 -13
  62. package/src/orchestration/flow.ts +9 -2
  63. package/src/orchestration/reducers.ts +9 -5
  64. package/src/orchestration/render.ts +1 -1
  65. package/src/orchestration/sdk.ts +17 -5
  66. package/src/orchestration/strategies/compete.ts +5 -1
  67. package/src/orchestration/strategies/council-rounds.ts +2 -0
  68. package/src/orchestration/strategies/critic-loop.ts +3 -0
  69. package/src/orchestration/strategies/debate.ts +2 -0
  70. package/src/orchestration/strategies/fanout.ts +3 -2
  71. package/src/orchestration/strategies/judge.ts +5 -1
  72. package/src/orchestration/strategies/magi.ts +40 -23
  73. package/src/orchestration/strategies/map.ts +13 -3
  74. package/src/orchestration/strategies/pair.ts +2 -0
  75. package/src/orchestration/strategies/pipeline.ts +2 -0
  76. package/src/orchestration/strategies/synthesize.ts +27 -7
  77. package/src/orchestration/strategy.ts +31 -0
  78. package/src/persona/persona.ts +7 -3
  79. package/src/telemetry/contract.ts +1 -1
  80. package/src/tools/background-run.ts +151 -0
  81. package/src/tools/council.ts +77 -52
  82. package/src/tools/delegate-tool.ts +310 -280
  83. package/src/tools/delegate.ts +22 -210
  84. package/src/tools/exocom-work.ts +18 -53
  85. package/src/tools/exocom.ts +15 -19
  86. package/src/tools/flow.ts +85 -28
  87. package/src/tools/intercom-tool.ts +108 -118
  88. package/src/tools/intercom.ts +1 -1
  89. package/src/tools/models.ts +19 -9
  90. package/src/tools/monitor.ts +39 -6
  91. package/src/tools/timer.ts +11 -13
  92. package/src/ui/agent-overlay.ts +55 -14
  93. package/src/ui/agent-tree.ts +84 -18
  94. package/src/ui/compact-widget.ts +25 -0
  95. package/src/ui/failure-summary.ts +55 -0
  96. package/src/ui/literal-tool-result.ts +24 -0
  97. package/src/ui/model-picker.ts +158 -81
  98. package/src/ui/persona-settings.ts +9 -3
  99. package/src/ui/presentation.ts +24 -0
  100. package/src/ui/report-body.ts +63 -0
  101. package/src/ui/session-cards.ts +189 -0
  102. package/src/ui/wake-rows.ts +262 -0
  103. package/teams.yaml +5 -1
@@ -0,0 +1,55 @@
1
+ import { compactInlineText, sanitizeTerminalText } from "./presentation.ts";
2
+
3
+ const MAX_INSPECTED_CHARS = 16_384;
4
+ const MAX_SUMMARY_COLUMNS = 140;
5
+
6
+ /** Display-only cause preview. The caller retains the original error for diagnostics. */
7
+ export function summarizeFailure(error: string): string {
8
+ let source = sanitizeTerminalText(error.slice(0, MAX_INSPECTED_CHARS)).trim();
9
+ // Omit only the runtime's identity/model wrapper, not meaningful tags such as [BLOCKED].
10
+ const wrapperEnd = source.startsWith("[") ? source.indexOf("]") : -1;
11
+ if (wrapperEnd > 0 && wrapperEnd <= 512 && source.slice(1, wrapperEnd).includes(" · ")) source = source.slice(wrapperEnd + 1).trim();
12
+ const reason = sanitizeTerminalText(jsonCause(source) ?? source).replace(/\s+/g, " ").trim();
13
+ if (/^(?:(?:the|this)\s+)?(?:['"`][^'"`]{1,200}['"`]\s+)?model\s+is\s+not\s+supported\s+when\s+using\s+Codex\s+with\s+a\s+ChatGPT\s+account[.!]?$/i.test(reason)) {
14
+ return "Model not supported by this ChatGPT account.";
15
+ }
16
+ return compactInlineText(reason, { maxChars: MAX_SUMMARY_COLUMNS });
17
+ }
18
+
19
+ /** Scan one bounded object, respecting JSON strings; never retry parsing at every brace. */
20
+ function jsonCause(source: string): string | undefined {
21
+ const start = source.indexOf("{");
22
+ if (start < 0) return undefined;
23
+ let depth = 0;
24
+ let quoted = false;
25
+ let escaped = false;
26
+ for (let index = start; index < source.length; index++) {
27
+ const char = source[index];
28
+ if (quoted) {
29
+ if (escaped) escaped = false;
30
+ else if (char === "\\") escaped = true;
31
+ else if (char === '"') quoted = false;
32
+ } else if (char === '"') quoted = true;
33
+ else if (char === "{") depth++;
34
+ else if (char === "}" && --depth === 0) {
35
+ try { return objectCause(JSON.parse(source.slice(start, index + 1)) as unknown); }
36
+ catch { return undefined; }
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ function objectCause(value: unknown): string | undefined {
43
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
44
+ const object = value as Record<string, unknown>;
45
+ for (const field of [object.detail, object.message, object.error]) {
46
+ if (typeof field === "string" && field.trim()) return field;
47
+ }
48
+ if (object.error !== null && typeof object.error === "object" && !Array.isArray(object.error)) {
49
+ const error = object.error as Record<string, unknown>;
50
+ for (const field of [error.message, error.detail]) {
51
+ if (typeof field === "string" && field.trim()) return field;
52
+ }
53
+ }
54
+ return undefined;
55
+ }
@@ -0,0 +1,24 @@
1
+ /** One compact renderer for literal control receipts and diagnostic fallbacks. Pi owns the shell. */
2
+ import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import { compactVisibleText, sanitizeTerminalText } from "./presentation.ts";
5
+
6
+ interface LiteralResult {
7
+ content: readonly { type: string; text?: string }[];
8
+ /** Legacy direct renderer calls may carry this; the native host passes it in context. */
9
+ isError?: boolean;
10
+ }
11
+
12
+ export function renderLiteralToolResult(
13
+ result: LiteralResult,
14
+ { expanded }: { expanded: boolean },
15
+ theme: Theme,
16
+ context?: { isError: boolean },
17
+ ): Text {
18
+ const full = sanitizeTerminalText(result.content.filter((part) => part.type === "text").map((part) => part.text ?? "").join("\n")) || "(no output)";
19
+ const failed = context?.isError ?? result.isError ?? false;
20
+ const preview = expanded ? undefined : compactVisibleText(full, { maxLines: 4, maxLineChars: 100 });
21
+ const heading = failed ? `${theme.fg("error", theme.bold("failed"))}\n` : "";
22
+ const hint = preview?.truncated ? `\n${theme.fg("dim", keyHint("app.tools.expand", "to expand"))}` : "";
23
+ return new Text(`${heading}${theme.fg("toolOutput", preview?.text ?? full)}${hint}`, 0, 0);
24
+ }
@@ -5,12 +5,32 @@
5
5
  * hundreds of models) and the viewport *follows the selection* — the highlighted
6
6
  * row is always on screen, so picking never jumps off the top.
7
7
  *
8
- * The pure parts (filter / viewport window / ordering) are exported and unit-tested;
9
- * this file's component is the thin pi-tui glue (focus + keyboard + framing).
8
+ * Machine-facing filtering/ordering remain separate from human fuzzy search.
9
+ * Only caller-supplied options are eligible; metadata never adds choices.
10
10
  */
11
11
 
12
- import type { Theme } from "@earendil-works/pi-coding-agent";
13
- import { Container, getKeybindings, Spacer, Text, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
12
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
13
+ import {
14
+ Container, CURSOR_MARKER, type Focusable, fuzzyFilter, getKeybindings, Input,
15
+ renderFakeCursor, setKeybindings, type TUI, truncateToWidth, visibleWidth,
16
+ } from "@earendil-works/pi-tui";
17
+ import { sanitizeTerminalText } from "./presentation.ts";
18
+
19
+ /** Structural subset of host snapshots; no registry lookup or availability policy here. */
20
+ export interface ModelPickerMetadata {
21
+ provider: string;
22
+ id: string;
23
+ name?: string;
24
+ contextWindow?: number;
25
+ reasoning?: boolean;
26
+ input?: readonly string[];
27
+ }
28
+
29
+ export interface ModelPickerConfig {
30
+ currentRef?: string;
31
+ models?: readonly ModelPickerMetadata[];
32
+ keybindings?: KeybindingsManager;
33
+ }
14
34
 
15
35
  const VIEWPORT = 12; // rows shown at once (the rest scrolls)
16
36
 
@@ -38,24 +58,48 @@ export function orderModelRefs(refs: string[], sessionProvider?: string): string
38
58
  return [...refs].sort((a, b) => Number(!isSession(a)) - Number(!isSession(b)) || a.localeCompare(b));
39
59
  }
40
60
 
41
- /** Is this keystroke a single printable character (a filter keystroke)? */
42
- function isPrintable(key: string): boolean {
43
- if (key.length !== 1) return false;
44
- const c = key.charCodeAt(0);
45
- return c >= 0x20 && c !== 0x7f;
61
+ /** One safe display line; never use this to rewrite a returned model ref. */
62
+ function displayText(value: string): string {
63
+ return sanitizeTerminalText(value)
64
+ .replace(/[\n\r\t\u2028\u2029]/g, " ")
65
+ .replace(/[\u202a-\u202e\u2066-\u2069]/g, "");
66
+ }
67
+
68
+ function metadataText(model: ModelPickerMetadata | undefined): string {
69
+ if (!model) return "";
70
+ const parts: string[] = [];
71
+ const context = model.contextWindow;
72
+ if (context !== undefined && Number.isSafeInteger(context) && context > 0) {
73
+ const size = context >= 1_000_000 ? `${Math.round(context / 100_000) / 10}m`
74
+ : context >= 1000 ? `${Math.round(context / 100) / 10}k` : String(context);
75
+ parts.push(`${size} ctx`);
76
+ }
77
+ if (model.reasoning === true) parts.push("reasoning");
78
+ if (model.input?.includes("image")) parts.push("vision");
79
+ // Functional metadata gets scarce columns before a potentially long/duplicated display name.
80
+ if (model.name && model.name !== model.id) parts.push(truncateToWidth(displayText(model.name), 32));
81
+ return parts.length ? ` · ${parts.join(" · ")}` : "";
46
82
  }
47
83
 
48
- export class ModelPicker extends Container {
84
+ export class ModelPicker extends Container implements Focusable {
49
85
  private theme: Theme;
50
86
  private title: string;
51
87
  private options: string[];
52
88
  private sessionProvider: string | undefined;
89
+ private currentRef: string | undefined;
53
90
  private done: (ref: string | undefined) => void;
54
91
  private tui: TUI;
55
- private query = "";
92
+ private input: Input;
93
+ private keybindings: ReturnType<typeof getKeybindings>;
94
+ private models = new Map<string, ModelPickerMetadata>();
95
+ private list: string[];
56
96
  private selected = 0;
57
97
  private scroll = 0;
58
- private lastWidth = 100;
98
+ private pastePending = false;
99
+ private pasteTail = "";
100
+
101
+ get focused(): boolean { return this.input.focused; }
102
+ set focused(value: boolean) { this.input.focused = value; }
59
103
 
60
104
  constructor(
61
105
  tui: TUI,
@@ -64,97 +108,130 @@ export class ModelPicker extends Container {
64
108
  options: string[],
65
109
  sessionProvider: string | undefined,
66
110
  done: (ref: string | undefined) => void,
111
+ config: ModelPickerConfig = {},
67
112
  ) {
68
113
  super();
69
114
  this.tui = tui;
70
115
  this.theme = theme;
71
- this.title = title;
72
- this.options = options;
116
+ this.title = displayText(title);
117
+ this.options = [...options];
118
+ this.list = this.options;
73
119
  this.sessionProvider = sessionProvider;
120
+ this.currentRef = config.currentRef;
74
121
  this.done = done;
75
- this.rebuild();
76
- }
77
-
78
- private refresh(): void {
79
- this.rebuild();
80
- this.tui.requestRender();
81
- }
82
-
83
- private filtered(): string[] {
84
- return filterModels(this.options, this.query);
85
- }
86
-
87
- override render(width: number): string[] {
88
- this.lastWidth = width;
89
- const inner = this.inner();
90
- const b = (s: string): string => this.theme.fg("accent", s);
91
- const framed = super.render(inner).map((line) => {
92
- const pad = " ".repeat(Math.max(0, inner - visibleWidth(line)));
93
- return `${b("│")} ${line}${pad} ${b("│")}`;
122
+ this.keybindings = config.keybindings ?? getKeybindings();
123
+ for (const model of config.models ?? []) this.models.set(`${model.provider}/${model.id}`, model);
124
+ this.selected = Math.max(0, this.list.indexOf(config.currentRef ?? ""));
125
+ this.input = new Input({
126
+ prompt: "",
127
+ placeholder: "Search name, id or provider",
128
+ placeholderStyle: (text) => this.theme.fg("dim", text),
94
129
  });
95
- return [b(`┌${"─".repeat(inner + 2)}┐`), ...framed, b(`└${"─".repeat(inner + 2)}┘`)];
130
+ this.addChild(this.input);
96
131
  }
97
132
 
98
- private inner(): number {
99
- return Math.max(24, Math.min(this.lastWidth - 4, 100));
133
+ private search(): void {
134
+ const previous = this.list[this.selected];
135
+ const query = this.input.getValue().trim();
136
+ this.list = fuzzyFilter(this.options, query, (ref) =>
137
+ displayText(`${ref} ${this.models.get(ref)?.name ?? ""}`));
138
+ // Full qualified refs beat name/token scores, without changing the caller's order at rest.
139
+ if (query) {
140
+ const literal = this.list.indexOf(query);
141
+ const exact = literal >= 0 ? literal : this.list.findIndex((ref) => ref.toLowerCase() === query.toLowerCase());
142
+ if (exact >= 0) {
143
+ if (exact > 0) this.list.unshift(...this.list.splice(exact, 1));
144
+ this.selected = 0; // typing an exact ref must not confirm a surviving fuzzy sibling
145
+ return;
146
+ }
147
+ }
148
+ this.selected = previous === undefined ? 0 : Math.max(0, this.list.indexOf(previous));
100
149
  }
101
150
 
102
- private rebuild(): void {
103
- this.clear();
151
+ override render(width: number): string[] {
152
+ const outer = Math.max(0, Math.min(104, Math.floor(width)));
153
+ if (outer === 0) return [];
154
+ const framed = outer >= 8;
155
+ const inner = framed ? outer - 4 : outer;
104
156
  const t = this.theme;
105
- const list = this.filtered();
106
- if (this.selected >= list.length) this.selected = Math.max(0, list.length - 1);
107
- this.scroll = visibleWindow(this.selected, VIEWPORT, list.length, this.scroll);
108
- const w = this.inner() - 1;
109
-
110
- this.addChild(new Text(t.fg("accent", t.bold(this.title)), 1, 0));
111
- const counter = t.fg("dim", `type to filter · ${list.length}/${this.options.length}`);
112
- const q = this.query ? t.fg("accent", ` «${this.query}»`) : "";
113
- this.addChild(new Text(`${counter}${q}`, 1, 0));
114
- this.addChild(new Spacer(1));
115
-
116
- if (list.length === 0) {
117
- this.addChild(new Text(t.fg("dim", "(no model matches — backspace to widen)"), 1, 0));
157
+ const fit = (line: string): string => truncateToWidth(line, inner, "…");
158
+ let inputLine = this.input.render(inner)[0] ?? "";
159
+ // Native Input can overrun a one-column viewport with a wide cursor grapheme.
160
+ if (visibleWidth(inputLine) > inner) {
161
+ inputLine = `${this.focused ? CURSOR_MARKER : ""}${renderFakeCursor(" ")}`;
162
+ }
163
+ const lines = [
164
+ t.fg("accent", t.bold(fit(this.title))),
165
+ t.fg("dim", fit(`${this.list.length}/${this.options.length} · fuzzy search`)),
166
+ inputLine,
167
+ "",
168
+ ];
169
+ this.scroll = visibleWindow(this.selected, VIEWPORT, this.list.length, this.scroll);
170
+ if (!this.list.length) {
171
+ lines.push(t.fg("dim", fit("(no model matches — edit search to widen)")));
118
172
  } else {
119
- const end = Math.min(list.length, this.scroll + VIEWPORT);
120
- if (this.scroll > 0) this.addChild(new Text(t.fg("dim", `▲ ${this.scroll} above`), 1, 0));
173
+ const end = Math.min(this.list.length, this.scroll + VIEWPORT);
174
+ if (this.scroll > 0) lines.push(t.fg("dim", fit(`▲ ${this.scroll} above`)));
121
175
  for (let i = this.scroll; i < end; i++) {
122
- const opt = list[i] as string;
123
- const star = this.sessionProvider && opt.startsWith(`${this.sessionProvider}/`) ? "★ " : " ";
124
- const text = truncateToWidth(`${star}${opt}`, w);
125
- const row = i === this.selected ? t.fg("accent", `▸ ${text}`) : ` ${text}`;
126
- this.addChild(new Text(row, 1, 0));
176
+ const ref = this.list[i]!;
177
+ const current = ref === this.currentRef ? "✓" : " ";
178
+ const star = this.sessionProvider && ref.startsWith(`${this.sessionProvider}/`) ? "★" : " ";
179
+ // Ref comes first; friendly name and bounded, explicit metadata spend only spare columns.
180
+ const row = fit(`${i === this.selected ? "▸" : " "} ${current}${star} ${displayText(ref)}${metadataText(this.models.get(ref))}`);
181
+ lines.push(i === this.selected ? t.fg("accent", row) : row);
127
182
  }
128
- if (end < list.length) this.addChild(new Text(t.fg("dim", `▼ ${list.length - end} below`), 1, 0));
183
+ if (end < this.list.length) lines.push(t.fg("dim", fit(`▼ ${this.list.length - end} below`)));
129
184
  }
130
-
131
- this.addChild(new Spacer(1));
132
- this.addChild(new Text(t.fg("dim", "↑↓ move · ⏎ select · esc keep default"), 1, 0));
185
+ const keys = (action: Parameters<typeof this.keybindings.getKeys>[0]): string => this.keybindings.getKeys(action).join("/");
186
+ lines.push("", t.fg("dim", fit("✓ current · ★ session provider")), t.fg("dim", fit(
187
+ `${keys("tui.select.up")}/${keys("tui.select.down")} move · ${keys("tui.select.confirm")} select · ${keys("tui.select.cancel")} keep default`,
188
+ )));
189
+ if (!framed) return lines;
190
+ const b = (text: string): string => t.fg("accent", text);
191
+ return [
192
+ b(`┌${"─".repeat(inner + 2)}┐`),
193
+ ...lines.map((line) => `${b("│")} ${line}${" ".repeat(Math.max(0, inner - visibleWidth(line)))} ${b("│")}`),
194
+ b(`└${"─".repeat(inner + 2)}┘`),
195
+ ];
133
196
  }
134
197
 
135
198
  handleInput(keyData: string): void {
136
- const kb = getKeybindings();
137
- const list = this.filtered();
138
- if (kb.matches(keyData, "tui.select.up")) {
199
+ const kb = this.keybindings;
200
+ // While Input buffers a bracketed paste, even a standalone Enter/Escape
201
+ // chunk is text, not a picker action. Retain only enough tail to detect
202
+ // an end marker split across chunks; Input owns the actual paste buffer.
203
+ const pasteData = this.pasteTail + keyData;
204
+ const editingPaste = this.pastePending || keyData.includes("\x1b[200~");
205
+ this.pastePending = editingPaste && !pasteData.includes("\x1b[201~");
206
+ this.pasteTail = this.pastePending ? pasteData.slice(-5) : "";
207
+ if (!editingPaste && kb.matches(keyData, "tui.select.up")) {
139
208
  this.selected = Math.max(0, this.selected - 1);
140
- this.refresh();
141
- } else if (kb.matches(keyData, "tui.select.down")) {
142
- this.selected = Math.min(Math.max(0, list.length - 1), this.selected + 1);
143
- this.refresh();
144
- } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n" || keyData === "\r") {
145
- this.done(list[this.selected]);
146
- } else if (kb.matches(keyData, "tui.select.cancel")) {
209
+ } else if (!editingPaste && kb.matches(keyData, "tui.select.down")) {
210
+ this.selected = Math.min(Math.max(0, this.list.length - 1), this.selected + 1);
211
+ } else if (!editingPaste && kb.matches(keyData, "tui.select.confirm")) {
212
+ const ref = this.list[this.selected];
213
+ if (ref !== undefined) this.done(ref);
214
+ return;
215
+ } else if (!editingPaste && kb.matches(keyData, "tui.select.cancel")) {
147
216
  this.done(undefined);
148
- } else if (keyData === "\x7f" || keyData === "\b") {
149
- if (this.query) {
150
- this.query = this.query.slice(0, -1);
151
- this.selected = 0;
152
- this.refresh();
217
+ return;
218
+ } else {
219
+ const before = this.input.getValue();
220
+ // Input has no per-instance keybindings API (Pi 1.3/1.4). Scope its global
221
+ // lookup to this synchronous call and restore it even if handling throws.
222
+ const global = getKeybindings();
223
+ try {
224
+ if (global !== kb) setKeybindings(kb);
225
+ this.input.handleInput(keyData);
226
+ } finally {
227
+ if (global !== kb) setKeybindings(global);
153
228
  }
154
- } else if (isPrintable(keyData)) {
155
- this.query += keyData;
156
- this.selected = 0;
157
- this.refresh();
229
+ const value = this.input.getValue();
230
+ const safe = displayText(value);
231
+ if (safe !== value) this.input.setValue(safe);
232
+ if (before !== safe) this.search();
158
233
  }
234
+ this.invalidate();
235
+ this.tui.requestRender();
159
236
  }
160
237
  }
@@ -225,8 +225,14 @@ export class PersonaSettingsPanel implements Component {
225
225
  const title = this.theme.fg("accent", this.theme.bold("Persona settings"));
226
226
  const file = fitLine(this.theme.fg("dim", `Configuration file: ${compactInlineText(this.data.file, { maxChars: 240 })}`), pageWidth);
227
227
  const remembered = fitLine(this.theme.fg("dim", `Remembered persona: ${this.data.lastPersona ? compactInlineText(this.data.lastPersona, { maxChars: 120 }) : "none"}`), pageWidth);
228
+ // Show the configured primary binding; listing every alias would hide close/edit on narrow screens.
229
+ const keys = (action: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel"): string =>
230
+ this.keybindings.getKeys(action).slice(0, 1).map((key) => key === "escape" ? "esc" : key).join("/") || "unbound";
231
+ const move = `${keys("tui.select.up")}/${keys("tui.select.down")}`;
232
+ const confirm = keys("tui.select.confirm");
233
+ const cancel = keys("tui.select.cancel");
228
234
  const filter = this.searchMode
229
- ? this.theme.fg("accent", `Filter (type, backspace, enter): ${compactInlineText(this.query, { maxChars: 120 })}▌`)
235
+ ? this.theme.fg("accent", `Filter (type, backspace, ${confirm}): ${compactInlineText(this.query, { maxChars: 120 })}▌`)
230
236
  : this.query
231
237
  ? this.theme.fg("dim", `Filter: ${compactInlineText(this.query, { maxChars: 120 })}`)
232
238
  : this.theme.fg("dim", "Filter: press / to search");
@@ -234,8 +240,8 @@ export class PersonaSettingsPanel implements Component {
234
240
  const footer = [
235
241
  ...(this.data.notice ? [fitLine(this.theme.fg("warning", compactInlineText(this.data.notice, { maxChars: 200 })), pageWidth)] : []),
236
242
  ...(pageWidth < 76
237
- ? ["↑↓ move enter edit r reset", "/ filter m models d info esc close"]
238
- : ["↑↓ move enter edit r reset / filter m models d diagnostics esc close"])
243
+ ? [`${move} move ${confirm} edit r reset`, `/ filter m models d info ${cancel} close`]
244
+ : [`${move} move ${confirm} edit r reset / filter m models d diagnostics ${cancel} close`])
239
245
  .map((hint) => fitLine(this.theme.fg("dim", hint), pageWidth)),
240
246
  ];
241
247
  const bodyHeight = Math.max(1, terminalRows - header.length - footer.length);
@@ -168,3 +168,27 @@ export function compactVisibleText(
168
168
  originalChars: input.length,
169
169
  };
170
170
  }
171
+
172
+ /**
173
+ * The prompt an operator reads before releasing a flow checkpoint: which gate, what runs next,
174
+ * and enough of the gated phase's output to decide. The prompt is modal, so it cannot point at an
175
+ * "expand" key; it states how much it left out instead.
176
+ */
177
+ export function gateReviewText(gate: {
178
+ flow: string;
179
+ phase: string;
180
+ /** The phases the approval releases. */
181
+ next: ReadonlyArray<{ id: string; strategy: string }>;
182
+ output: string;
183
+ }): string {
184
+ const name = (value: string): string => compactInlineText(value, { maxChars: 64 });
185
+ const body = compactVisibleText(gate.output, { maxLines: 12, maxLineChars: 100 });
186
+ return [
187
+ `Checkpoint "${name(gate.phase)}" of flow "${name(gate.flow)}" — approve and continue?`,
188
+ ...(gate.next.length > 0 ? [`then: ${gate.next.map((p) => `${name(p.id)} (${name(p.strategy)})`).join(", ")}`] : []),
189
+ // Everything below is the child's text: a fixed rule keeps it from passing as more prompt.
190
+ "--- gated output (untrusted) ---",
191
+ body.text,
192
+ ...(body.truncated ? [`(+${Math.max(0, gate.output.length - body.text.length)} characters not shown)`] : []),
193
+ ].join("\n");
194
+ }
@@ -0,0 +1,63 @@
1
+ /** UI-only prose projection. Never rewrites stored/model-facing text or unwraps trust fences. */
2
+ import { getMarkdownTheme, type Theme, type ThemeBg } from "@earendil-works/pi-coding-agent";
3
+ import { Markdown, Text, truncateToWidth, type Component } from "@earendil-works/pi-tui";
4
+ import { sanitizeTerminalText } from "./presentation.ts";
5
+
6
+ interface ReportBodyOptions {
7
+ /** Trusted chrome stays literal Text, outside the Markdown body. Evaluated at paint time. */
8
+ header?: (width: number) => string;
9
+ /** Custom session cards only. Normal tools already have the host's shell. */
10
+ background?: ThemeBg;
11
+ padding?: number;
12
+ }
13
+
14
+ class ReportBody implements Component {
15
+ private readonly markdownTheme = getMarkdownTheme();
16
+ private readonly markdown: Markdown;
17
+ readonly source: string;
18
+ private theme: Theme;
19
+ private options: ReportBodyOptions;
20
+ constructor(source: string, theme: Theme, options: ReportBodyOptions) {
21
+ this.source = source;
22
+ this.theme = theme;
23
+ this.options = options;
24
+ // No defaultTextStyle: Pi caches its default style prefix independently of invalidate().
25
+ // Apply the fallback foreground/background outside Markdown so theme changes stay fresh.
26
+ this.markdown = new Markdown(source, 0, 0, this.markdownTheme);
27
+ }
28
+ update(theme: Theme, options: ReportBodyOptions): void {
29
+ this.theme = theme;
30
+ this.options = options;
31
+ this.invalidate();
32
+ }
33
+ invalidate(): void {
34
+ Object.assign(this.markdownTheme, getMarkdownTheme());
35
+ // Native Markdown retains its source-token cache while clearing themed/layout output.
36
+ this.markdown.invalidate();
37
+ }
38
+ render(width: number): string[] {
39
+ if (width <= 0) return [];
40
+ const padding = Math.min(Math.max(0, Math.floor(this.options.padding ?? 0)), Math.max(0, Math.floor((width - 1) / 2)));
41
+ const innerWidth = Math.max(1, width - 2 * padding);
42
+ const header = this.options.header?.(innerWidth);
43
+ const lines = [
44
+ ...(header ? new Text(header, 0, 0).render(innerWidth) : []),
45
+ ...this.markdown.render(innerWidth).map((line) => this.theme.fg("toolOutput", line)),
46
+ ];
47
+ const background = this.options.background;
48
+ return new Text(lines.join("\n"), padding, 0, background ? (text) => this.theme.bg(background, text) : undefined)
49
+ .render(width).map((line) => truncateToWidth(line, width));
50
+ }
51
+ }
52
+
53
+ /** Keep one native Markdown instance per report. Width changes reflow natively; invalidate() refreshes
54
+ * theme/layout, not source tokens. Tool callbacks can also reuse Pi's lastComponent across redraws.
55
+ * No global transcript/content cache: old reports are reclaimed with their host components. */
56
+ export function createReportBody(text: string, theme: Theme, options: ReportBodyOptions = {}, previous?: Component): Component {
57
+ const source = sanitizeTerminalText(text);
58
+ if (previous instanceof ReportBody && previous.source === source) {
59
+ previous.update(theme, options);
60
+ return previous;
61
+ }
62
+ return new ReportBody(source, theme, options);
63
+ }