@aeondave/pi-persona 1.17.0 → 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 (109) hide show
  1. package/AGENTS.md +79 -29
  2. package/CHANGELOG.md +264 -0
  3. package/README.md +27 -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 +223 -57
  15. package/docs/EXPERIENCE.md +19 -9
  16. package/docs/MONITORS.md +62 -3
  17. package/docs/REFERENCE.md +189 -35
  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 +16 -11
  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 +251 -0
  45. package/src/core/timer.ts +34 -9
  46. package/src/engine/adapter.ts +12 -8
  47. package/src/engine/async.ts +195 -136
  48. package/src/engine/child.ts +1 -1
  49. package/src/engine/host-module.ts +12 -0
  50. package/src/engine/inproc.ts +11 -6
  51. package/src/engine/stream.ts +3 -0
  52. package/src/exocom/install.ts +2 -1
  53. package/src/extension/companion-settings.ts +29 -0
  54. package/src/extension/engine.ts +43 -16
  55. package/src/extension/hooks.ts +106 -36
  56. package/src/extension/persona-model-settings.ts +336 -0
  57. package/src/extension/persona-settings.ts +459 -0
  58. package/src/extension/shared.ts +15 -7
  59. package/src/extension.ts +642 -267
  60. package/src/monitor/archive.ts +235 -0
  61. package/src/monitor/session.ts +21 -1
  62. package/src/orchestration/flow-journal.ts +26 -5
  63. package/src/orchestration/flow-run.ts +38 -13
  64. package/src/orchestration/flow.ts +9 -2
  65. package/src/orchestration/reducers.ts +9 -5
  66. package/src/orchestration/render.ts +1 -1
  67. package/src/orchestration/sdk.ts +17 -3
  68. package/src/orchestration/strategies/compete.ts +5 -1
  69. package/src/orchestration/strategies/council-rounds.ts +2 -0
  70. package/src/orchestration/strategies/critic-loop.ts +3 -0
  71. package/src/orchestration/strategies/debate.ts +2 -0
  72. package/src/orchestration/strategies/fanout.ts +3 -2
  73. package/src/orchestration/strategies/judge.ts +5 -1
  74. package/src/orchestration/strategies/magi.ts +40 -23
  75. package/src/orchestration/strategies/map.ts +13 -3
  76. package/src/orchestration/strategies/pair.ts +2 -0
  77. package/src/orchestration/strategies/pipeline.ts +2 -0
  78. package/src/orchestration/strategies/synthesize.ts +27 -7
  79. package/src/orchestration/strategy.ts +31 -0
  80. package/src/persona/config-store.ts +6 -195
  81. package/src/persona/model-participants.ts +1 -1
  82. package/src/persona/persona.ts +7 -3
  83. package/src/persona/settings-store.ts +636 -0
  84. package/src/telemetry/contract.ts +1 -1
  85. package/src/tools/background-run.ts +151 -0
  86. package/src/tools/council.ts +77 -52
  87. package/src/tools/delegate-tool.ts +315 -285
  88. package/src/tools/delegate.ts +22 -210
  89. package/src/tools/exocom-work.ts +18 -53
  90. package/src/tools/exocom.ts +15 -19
  91. package/src/tools/flow.ts +85 -28
  92. package/src/tools/intercom-tool.ts +108 -118
  93. package/src/tools/intercom.ts +1 -1
  94. package/src/tools/models.ts +19 -9
  95. package/src/tools/monitor.ts +39 -6
  96. package/src/tools/timer.ts +11 -13
  97. package/src/ui/agent-overlay.ts +55 -14
  98. package/src/ui/agent-tree.ts +84 -18
  99. package/src/ui/compact-widget.ts +25 -0
  100. package/src/ui/failure-summary.ts +55 -0
  101. package/src/ui/literal-tool-result.ts +24 -0
  102. package/src/ui/model-picker.ts +158 -81
  103. package/src/ui/persona-settings.ts +328 -0
  104. package/src/ui/presentation.ts +24 -0
  105. package/src/ui/report-body.ts +63 -0
  106. package/src/ui/session-cards.ts +189 -0
  107. package/src/ui/wake-rows.ts +262 -0
  108. package/teams.yaml +5 -1
  109. package/src/persona/state.ts +0 -28
@@ -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
  }