@oh-my-pi/pi-coding-agent 13.14.0 → 13.15.2

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 (90) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/package.json +10 -8
  3. package/src/autoresearch/command-initialize.md +34 -0
  4. package/src/autoresearch/command-resume.md +17 -0
  5. package/src/autoresearch/contract.ts +332 -0
  6. package/src/autoresearch/dashboard.ts +447 -0
  7. package/src/autoresearch/git.ts +243 -0
  8. package/src/autoresearch/helpers.ts +458 -0
  9. package/src/autoresearch/index.ts +693 -0
  10. package/src/autoresearch/prompt.md +227 -0
  11. package/src/autoresearch/resume-message.md +16 -0
  12. package/src/autoresearch/state.ts +386 -0
  13. package/src/autoresearch/tools/init-experiment.ts +310 -0
  14. package/src/autoresearch/tools/log-experiment.ts +833 -0
  15. package/src/autoresearch/tools/run-experiment.ts +640 -0
  16. package/src/autoresearch/types.ts +218 -0
  17. package/src/cli/args.ts +8 -2
  18. package/src/cli/initial-message.ts +58 -0
  19. package/src/config/keybindings.ts +417 -212
  20. package/src/config/model-registry.ts +1 -0
  21. package/src/config/model-resolver.ts +57 -9
  22. package/src/config/settings-schema.ts +38 -10
  23. package/src/config/settings.ts +1 -4
  24. package/src/exec/bash-executor.ts +7 -5
  25. package/src/export/html/template.css +43 -13
  26. package/src/export/html/template.generated.ts +1 -1
  27. package/src/export/html/template.html +1 -0
  28. package/src/export/html/template.js +107 -0
  29. package/src/extensibility/extensions/types.ts +31 -8
  30. package/src/internal-urls/docs-index.generated.ts +1 -1
  31. package/src/lsp/index.ts +1 -1
  32. package/src/main.ts +44 -44
  33. package/src/mcp/oauth-discovery.ts +1 -1
  34. package/src/modes/acp/acp-agent.ts +957 -0
  35. package/src/modes/acp/acp-event-mapper.ts +531 -0
  36. package/src/modes/acp/acp-mode.ts +13 -0
  37. package/src/modes/acp/index.ts +2 -0
  38. package/src/modes/components/agent-dashboard.ts +5 -4
  39. package/src/modes/components/bash-execution.ts +40 -11
  40. package/src/modes/components/custom-editor.ts +47 -47
  41. package/src/modes/components/extensions/extension-dashboard.ts +2 -1
  42. package/src/modes/components/history-search.ts +2 -1
  43. package/src/modes/components/hook-editor.ts +2 -1
  44. package/src/modes/components/hook-input.ts +8 -7
  45. package/src/modes/components/hook-selector.ts +15 -10
  46. package/src/modes/components/keybinding-hints.ts +9 -9
  47. package/src/modes/components/login-dialog.ts +3 -3
  48. package/src/modes/components/mcp-add-wizard.ts +2 -1
  49. package/src/modes/components/model-selector.ts +14 -3
  50. package/src/modes/components/oauth-selector.ts +2 -1
  51. package/src/modes/components/python-execution.ts +2 -3
  52. package/src/modes/components/session-selector.ts +2 -1
  53. package/src/modes/components/settings-selector.ts +2 -1
  54. package/src/modes/components/status-line-segment-editor.ts +2 -1
  55. package/src/modes/components/tool-execution.ts +4 -5
  56. package/src/modes/components/tree-selector.ts +3 -2
  57. package/src/modes/components/user-message-selector.ts +3 -8
  58. package/src/modes/components/user-message.ts +16 -0
  59. package/src/modes/controllers/command-controller.ts +0 -2
  60. package/src/modes/controllers/extension-ui-controller.ts +89 -4
  61. package/src/modes/controllers/input-controller.ts +29 -23
  62. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  63. package/src/modes/index.ts +1 -0
  64. package/src/modes/interactive-mode.ts +17 -5
  65. package/src/modes/print-mode.ts +1 -1
  66. package/src/modes/prompt-action-autocomplete.ts +7 -7
  67. package/src/modes/rpc/rpc-mode.ts +7 -2
  68. package/src/modes/rpc/rpc-types.ts +1 -0
  69. package/src/modes/theme/theme.ts +53 -44
  70. package/src/modes/types.ts +9 -2
  71. package/src/modes/utils/hotkeys-markdown.ts +19 -19
  72. package/src/modes/utils/keybinding-matchers.ts +21 -0
  73. package/src/modes/utils/ui-helpers.ts +1 -1
  74. package/src/patch/hashline.ts +139 -127
  75. package/src/patch/index.ts +77 -59
  76. package/src/patch/shared.ts +19 -11
  77. package/src/prompts/tools/hashline.md +43 -116
  78. package/src/sdk.ts +34 -17
  79. package/src/session/agent-session.ts +123 -30
  80. package/src/session/session-manager.ts +32 -31
  81. package/src/session/streaming-output.ts +87 -37
  82. package/src/tools/ask.ts +56 -30
  83. package/src/tools/bash-interactive.ts +2 -6
  84. package/src/tools/bash-interceptor.ts +1 -39
  85. package/src/tools/bash-skill-urls.ts +1 -1
  86. package/src/tools/browser.ts +1 -1
  87. package/src/tools/gemini-image.ts +1 -1
  88. package/src/tools/python.ts +2 -2
  89. package/src/tools/resolve.ts +1 -1
  90. package/src/utils/child-process.ts +88 -0
@@ -1,41 +1,41 @@
1
1
  import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
2
- import type { AppAction } from "../../config/keybindings";
2
+ import type { AppKeybinding } from "../../config/keybindings";
3
3
 
4
4
  type ConfigurableEditorAction = Extract<
5
- AppAction,
6
- | "interrupt"
7
- | "clear"
8
- | "exit"
9
- | "suspend"
10
- | "cycleThinkingLevel"
11
- | "cycleModelForward"
12
- | "cycleModelBackward"
13
- | "selectModel"
14
- | "expandTools"
15
- | "toggleThinking"
16
- | "externalEditor"
17
- | "historySearch"
18
- | "dequeue"
19
- | "pasteImage"
20
- | "copyPrompt"
5
+ AppKeybinding,
6
+ | "app.interrupt"
7
+ | "app.clear"
8
+ | "app.exit"
9
+ | "app.suspend"
10
+ | "app.thinking.cycle"
11
+ | "app.model.cycleForward"
12
+ | "app.model.cycleBackward"
13
+ | "app.model.select"
14
+ | "app.tools.expand"
15
+ | "app.thinking.toggle"
16
+ | "app.editor.external"
17
+ | "app.history.search"
18
+ | "app.message.dequeue"
19
+ | "app.clipboard.pasteImage"
20
+ | "app.clipboard.copyPrompt"
21
21
  >;
22
22
 
23
23
  const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
24
- interrupt: ["escape"],
25
- clear: ["ctrl+c"],
26
- exit: ["ctrl+d"],
27
- suspend: ["ctrl+z"],
28
- cycleThinkingLevel: ["shift+tab"],
29
- cycleModelForward: ["ctrl+p"],
30
- cycleModelBackward: ["shift+ctrl+p"],
31
- selectModel: ["ctrl+l"],
32
- expandTools: ["ctrl+o"],
33
- toggleThinking: ["ctrl+t"],
34
- externalEditor: ["ctrl+g"],
35
- historySearch: ["ctrl+r"],
36
- dequeue: ["alt+up"],
37
- pasteImage: ["ctrl+v"],
38
- copyPrompt: ["alt+shift+c"],
24
+ "app.interrupt": ["escape"],
25
+ "app.clear": ["ctrl+c"],
26
+ "app.exit": ["ctrl+d"],
27
+ "app.suspend": ["ctrl+z"],
28
+ "app.thinking.cycle": ["shift+tab"],
29
+ "app.model.cycleForward": ["ctrl+p"],
30
+ "app.model.cycleBackward": ["shift+ctrl+p"],
31
+ "app.model.select": ["ctrl+l"],
32
+ "app.tools.expand": ["ctrl+o"],
33
+ "app.thinking.toggle": ["ctrl+t"],
34
+ "app.editor.external": ["ctrl+g"],
35
+ "app.history.search": ["ctrl+r"],
36
+ "app.message.dequeue": ["alt+up"],
37
+ "app.clipboard.pasteImage": ["ctrl+v"],
38
+ "app.clipboard.copyPrompt": ["alt+shift+c"],
39
39
  };
40
40
 
41
41
  /**
@@ -115,13 +115,13 @@ export class CustomEditor extends Editor {
115
115
  }
116
116
 
117
117
  // Intercept configured image paste (async - fires and handles result)
118
- if (this.#matchesAction(data, "pasteImage") && this.onPasteImage) {
118
+ if (this.#matchesAction(data, "app.clipboard.pasteImage") && this.onPasteImage) {
119
119
  void this.onPasteImage();
120
120
  return;
121
121
  }
122
122
 
123
123
  // Intercept configured external editor shortcut
124
- if (this.#matchesAction(data, "externalEditor") && this.onExternalEditor) {
124
+ if (this.#matchesAction(data, "app.editor.external") && this.onExternalEditor) {
125
125
  this.onExternalEditor();
126
126
  return;
127
127
  }
@@ -133,56 +133,56 @@ export class CustomEditor extends Editor {
133
133
  }
134
134
 
135
135
  // Intercept configured suspend shortcut
136
- if (this.#matchesAction(data, "suspend") && this.onSuspend) {
136
+ if (this.#matchesAction(data, "app.suspend") && this.onSuspend) {
137
137
  this.onSuspend();
138
138
  return;
139
139
  }
140
140
 
141
141
  // Intercept configured thinking block visibility toggle
142
- if (this.#matchesAction(data, "toggleThinking") && this.onToggleThinking) {
142
+ if (this.#matchesAction(data, "app.thinking.toggle") && this.onToggleThinking) {
143
143
  this.onToggleThinking();
144
144
  return;
145
145
  }
146
146
 
147
147
  // Intercept configured model selector shortcut
148
- if (this.#matchesAction(data, "selectModel") && this.onSelectModel) {
148
+ if (this.#matchesAction(data, "app.model.select") && this.onSelectModel) {
149
149
  this.onSelectModel();
150
150
  return;
151
151
  }
152
152
 
153
153
  // Intercept configured history search shortcut
154
- if (this.#matchesAction(data, "historySearch") && this.onHistorySearch) {
154
+ if (this.#matchesAction(data, "app.history.search") && this.onHistorySearch) {
155
155
  this.onHistorySearch();
156
156
  return;
157
157
  }
158
158
 
159
159
  // Intercept configured tool output expansion shortcut
160
- if (this.#matchesAction(data, "expandTools") && this.onExpandTools) {
160
+ if (this.#matchesAction(data, "app.tools.expand") && this.onExpandTools) {
161
161
  this.onExpandTools();
162
162
  return;
163
163
  }
164
164
 
165
165
  // Intercept configured backward model cycling (check before forward cycling)
166
- if (this.#matchesAction(data, "cycleModelBackward") && this.onCycleModelBackward) {
166
+ if (this.#matchesAction(data, "app.model.cycleBackward") && this.onCycleModelBackward) {
167
167
  this.onCycleModelBackward();
168
168
  return;
169
169
  }
170
170
 
171
171
  // Intercept configured forward model cycling
172
- if (this.#matchesAction(data, "cycleModelForward") && this.onCycleModelForward) {
172
+ if (this.#matchesAction(data, "app.model.cycleForward") && this.onCycleModelForward) {
173
173
  this.onCycleModelForward();
174
174
  return;
175
175
  }
176
176
 
177
177
  // Intercept configured thinking level cycling
178
- if (this.#matchesAction(data, "cycleThinkingLevel") && this.onCycleThinkingLevel) {
178
+ if (this.#matchesAction(data, "app.thinking.cycle") && this.onCycleThinkingLevel) {
179
179
  this.onCycleThinkingLevel();
180
180
  return;
181
181
  }
182
182
 
183
183
  // Intercept configured interrupt shortcut.
184
184
  // Default behavior keeps autocomplete dismissal, but parent can prioritize global interrupt handling.
185
- if (this.#matchesAction(data, "interrupt") && this.onEscape) {
185
+ if (this.#matchesAction(data, "app.interrupt") && this.onEscape) {
186
186
  if (!this.isShowingAutocomplete() || this.shouldBypassAutocompleteOnEscape?.()) {
187
187
  this.onEscape();
188
188
  return;
@@ -190,13 +190,13 @@ export class CustomEditor extends Editor {
190
190
  }
191
191
 
192
192
  // Intercept configured clear shortcut
193
- if (this.#matchesAction(data, "clear") && this.onClear) {
193
+ if (this.#matchesAction(data, "app.clear") && this.onClear) {
194
194
  this.onClear();
195
195
  return;
196
196
  }
197
197
 
198
198
  // Intercept configured exit shortcut (only when editor is empty)
199
- if (this.#matchesAction(data, "exit")) {
199
+ if (this.#matchesAction(data, "app.exit")) {
200
200
  if (this.getText().length === 0 && this.onExit) {
201
201
  this.onExit();
202
202
  }
@@ -205,13 +205,13 @@ export class CustomEditor extends Editor {
205
205
  }
206
206
 
207
207
  // Intercept configured dequeue shortcut (restore queued message to editor)
208
- if (this.#matchesAction(data, "dequeue") && this.onDequeue) {
208
+ if (this.#matchesAction(data, "app.message.dequeue") && this.onDequeue) {
209
209
  this.onDequeue();
210
210
  return;
211
211
  }
212
212
 
213
213
  // Intercept configured copy-prompt shortcut
214
- if (this.#matchesAction(data, "copyPrompt") && this.onCopyPrompt) {
214
+ if (this.#matchesAction(data, "app.clipboard.copyPrompt") && this.onCopyPrompt) {
215
215
  this.onCopyPrompt();
216
216
  return;
217
217
  }
@@ -24,6 +24,7 @@ import {
24
24
  import { Settings } from "../../../config/settings";
25
25
  import { DynamicBorder } from "../../../modes/components/dynamic-border";
26
26
  import { theme } from "../../../modes/theme/theme";
27
+ import { matchesAppInterrupt } from "../../../modes/utils/keybinding-matchers";
27
28
  import { ExtensionList } from "./extension-list";
28
29
  import { InspectorPanel } from "./inspector-panel";
29
30
  import { applyFilter, createInitialState, filterByProvider, refreshState, toggleProvider } from "./state-manager";
@@ -251,7 +252,7 @@ export class ExtensionDashboard extends Container {
251
252
  }
252
253
 
253
254
  // Escape - clear search first, then close
254
- if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
255
+ if (matchesAppInterrupt(data)) {
255
256
  if (this.#state.searchQuery.length > 0) {
256
257
  this.#state.searchQuery = "";
257
258
  this.#state.searchFiltered = this.#state.tabFiltered;
@@ -11,6 +11,7 @@ import {
11
11
  visibleWidth,
12
12
  } from "@oh-my-pi/pi-tui";
13
13
  import { theme } from "../../modes/theme/theme";
14
+ import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
14
15
  import type { HistoryEntry, HistoryStorage } from "../../session/history-storage";
15
16
  import { DynamicBorder } from "./dynamic-border";
16
17
 
@@ -137,7 +138,7 @@ export class HistorySearchComponent extends Container {
137
138
  return;
138
139
  }
139
140
 
140
- if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
141
+ if (matchesAppInterrupt(keyData)) {
141
142
  this.#onCancel();
142
143
  return;
143
144
  }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
6
6
  import { getEditorTheme, theme } from "../../modes/theme/theme";
7
+ import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
7
8
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
8
9
  import { DynamicBorder } from "./dynamic-border";
9
10
 
@@ -67,7 +68,7 @@ export class HookEditorComponent extends Container {
67
68
  }
68
69
 
69
70
  // Escape to cancel
70
- if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
71
+ if (matchesAppInterrupt(keyData)) {
71
72
  this.#onCancelCallback();
72
73
  return;
73
74
  }
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Simple text input component for hooks.
3
3
  */
4
- import { Container, Input, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
5
- import { theme } from "../../modes/theme/theme";
4
+ import { Container, Input, Markdown, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
5
+ import { getMarkdownTheme, theme } from "../../modes/theme/theme";
6
+ import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
6
7
  import { CountdownTimer } from "./countdown-timer";
7
8
  import { DynamicBorder } from "./dynamic-border";
8
9
 
@@ -16,7 +17,7 @@ export class HookInputComponent extends Container {
16
17
  #input: Input;
17
18
  #onSubmitCallback: (value: string) => void;
18
19
  #onCancelCallback: () => void;
19
- #titleText: Text;
20
+ #titleComponent: Markdown;
20
21
  #baseTitle: string;
21
22
  #countdown: CountdownTimer | undefined;
22
23
 
@@ -36,15 +37,15 @@ export class HookInputComponent extends Container {
36
37
  this.addChild(new DynamicBorder());
37
38
  this.addChild(new Spacer(1));
38
39
 
39
- this.#titleText = new Text(theme.fg("accent", title), 1, 0);
40
- this.addChild(this.#titleText);
40
+ this.#titleComponent = new Markdown(title, 1, 0, getMarkdownTheme(), { color: t => theme.fg("accent", t) });
41
+ this.addChild(this.#titleComponent);
41
42
  this.addChild(new Spacer(1));
42
43
 
43
44
  if (opts?.timeout && opts.timeout > 0 && opts.tui) {
44
45
  this.#countdown = new CountdownTimer(
45
46
  opts.timeout,
46
47
  opts.tui,
47
- s => this.#titleText.setText(theme.fg("accent", `${this.#baseTitle} (${s}s)`)),
48
+ s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`),
48
49
  () => {
49
50
  opts.onTimeout?.();
50
51
  this.#onCancelCallback();
@@ -65,7 +66,7 @@ export class HookInputComponent extends Container {
65
66
  this.#countdown?.reset();
66
67
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
67
68
  this.#onSubmitCallback(this.#input.getValue());
68
- } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
69
+ } else if (matchesAppInterrupt(keyData)) {
69
70
  this.#onCancelCallback();
70
71
  } else {
71
72
  this.#input.handleInput(keyData);
@@ -4,8 +4,10 @@
4
4
  */
5
5
  import {
6
6
  Container,
7
+ Markdown,
7
8
  matchesKey,
8
9
  padding,
10
+ renderInlineMarkdown,
9
11
  replaceTabs,
10
12
  Spacer,
11
13
  Text,
@@ -13,7 +15,8 @@ import {
13
15
  truncateToWidth,
14
16
  visibleWidth,
15
17
  } from "@oh-my-pi/pi-tui";
16
- import { theme } from "../../modes/theme/theme";
18
+ import { getMarkdownTheme, theme } from "../../modes/theme/theme";
19
+ import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
17
20
  import { CountdownTimer } from "./countdown-timer";
18
21
  import { DynamicBorder } from "./dynamic-border";
19
22
 
@@ -59,7 +62,7 @@ export class HookSelectorComponent extends Container {
59
62
  #outlinedList: OutlinedList | undefined;
60
63
  #onSelectCallback: (option: string) => void;
61
64
  #onCancelCallback: () => void;
62
- #titleText: Text;
65
+ #titleComponent: Markdown;
63
66
  #baseTitle: string;
64
67
  #countdown: CountdownTimer | undefined;
65
68
  #onLeftCallback: (() => void) | undefined;
@@ -85,15 +88,15 @@ export class HookSelectorComponent extends Container {
85
88
  this.addChild(new DynamicBorder());
86
89
  this.addChild(new Spacer(1));
87
90
 
88
- this.#titleText = new Text(theme.fg("accent", title), 1, 0);
89
- this.addChild(this.#titleText);
91
+ this.#titleComponent = new Markdown(title, 1, 0, getMarkdownTheme(), { color: t => theme.fg("accent", t) });
92
+ this.addChild(this.#titleComponent);
90
93
  this.addChild(new Spacer(1));
91
94
 
92
95
  if (opts?.timeout && opts.timeout > 0 && opts.tui) {
93
96
  this.#countdown = new CountdownTimer(
94
97
  opts.timeout,
95
98
  opts.tui,
96
- s => this.#titleText.setText(theme.fg("accent", `${this.#baseTitle} (${s}s)`)),
99
+ s => this.#titleComponent.setText(`${this.#baseTitle} (${s}s)`),
97
100
  () => {
98
101
  opts?.onTimeout?.();
99
102
  // Auto-select current option on timeout (typically the first/recommended option)
@@ -131,12 +134,14 @@ export class HookSelectorComponent extends Container {
131
134
  );
132
135
  const endIndex = Math.min(startIndex + this.#maxVisible, this.#options.length);
133
136
 
137
+ const mdTheme = getMarkdownTheme();
134
138
  for (let i = startIndex; i < endIndex; i++) {
135
139
  const isSelected = i === this.#selectedIndex;
136
- const text = isSelected
137
- ? theme.fg("accent", `${theme.nav.cursor} `) + theme.fg("accent", this.#options[i])
138
- : ` ${theme.fg("text", this.#options[i])}`;
139
- lines.push(text);
140
+ const label = isSelected
141
+ ? renderInlineMarkdown(this.#options[i], mdTheme, t => theme.fg("accent", t))
142
+ : renderInlineMarkdown(this.#options[i], mdTheme, t => theme.fg("text", t));
143
+ const prefix = isSelected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
144
+ lines.push(prefix + label);
140
145
  }
141
146
 
142
147
  if (startIndex > 0 || endIndex < this.#options.length) {
@@ -169,7 +174,7 @@ export class HookSelectorComponent extends Container {
169
174
  this.#onLeftCallback?.();
170
175
  } else if (matchesKey(keyData, "right")) {
171
176
  this.#onRightCallback?.();
172
- } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
177
+ } else if (matchesSelectCancel(keyData)) {
173
178
  this.#onCancelCallback();
174
179
  }
175
180
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Utilities for formatting keybinding hints in the UI.
3
3
  */
4
- import { type EditorAction, getEditorKeybindings, type KeyId } from "@oh-my-pi/pi-tui";
5
- import type { AppAction, KeybindingsManager } from "../../config/keybindings";
4
+ import { getKeybindings, type Keybinding, type KeyId } from "@oh-my-pi/pi-tui";
5
+ import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
6
6
  import { theme } from "../../modes/theme/theme";
7
7
 
8
8
  /**
@@ -17,14 +17,14 @@ function formatKeys(keys: KeyId[]): string {
17
17
  /**
18
18
  * Get display string for an editor action.
19
19
  */
20
- export function editorKey(action: EditorAction): string {
21
- return formatKeys(getEditorKeybindings().getKeys(action));
20
+ export function editorKey(action: Keybinding): string {
21
+ return formatKeys(getKeybindings().getKeys(action));
22
22
  }
23
23
 
24
24
  /**
25
25
  * Get display string for an app action.
26
26
  */
27
- export function appKey(keybindings: KeybindingsManager, action: AppAction): string {
27
+ export function appKey(keybindings: KeybindingsManager, action: AppKeybinding): string {
28
28
  return formatKeys(keybindings.getKeys(action));
29
29
  }
30
30
 
@@ -32,11 +32,11 @@ export function appKey(keybindings: KeybindingsManager, action: AppAction): stri
32
32
  * Format a keybinding hint with consistent styling: dim key, muted description.
33
33
  * Looks up the key from editor keybindings automatically.
34
34
  *
35
- * @param action - Editor action name (e.g., "selectConfirm", "expandTools")
35
+ * @param action - Keybinding action name (e.g., "tui.select.confirm", "app.tools.expand")
36
36
  * @param description - Description text (e.g., "to expand", "cancel")
37
37
  * @returns Formatted string with dim key and muted description
38
38
  */
39
- export function keyHint(action: EditorAction, description: string): string {
39
+ export function keyHint(action: Keybinding, description: string): string {
40
40
  return theme.fg("dim", editorKey(action)) + theme.fg("muted", ` ${description}`);
41
41
  }
42
42
 
@@ -45,11 +45,11 @@ export function keyHint(action: EditorAction, description: string): string {
45
45
  * Requires the KeybindingsManager instance.
46
46
  *
47
47
  * @param keybindings - KeybindingsManager instance
48
- * @param action - App action name (e.g., "interrupt", "externalEditor")
48
+ * @param action - App keybinding name (e.g., "app.interrupt", "app.editor.external")
49
49
  * @param description - Description text
50
50
  * @returns Formatted string with dim key and muted description
51
51
  */
52
- export function appKeyHint(keybindings: KeybindingsManager, action: AppAction, description: string): string {
52
+ export function appKeyHint(keybindings: KeybindingsManager, action: AppKeybinding, description: string): string {
53
53
  return theme.fg("dim", appKey(keybindings, action)) + theme.fg("muted", ` ${description}`);
54
54
  }
55
55
 
@@ -1,5 +1,5 @@
1
1
  import { getOAuthProviders } from "@oh-my-pi/pi-ai";
2
- import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
2
+ import { Container, getKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
3
3
  import { theme } from "../../modes/theme/theme";
4
4
  import { openPath } from "../../utils/open";
5
5
  import { DynamicBorder } from "./dynamic-border";
@@ -151,9 +151,9 @@ export class LoginDialogComponent extends Container {
151
151
  }
152
152
 
153
153
  handleInput(data: string): void {
154
- const kb = getEditorKeybindings();
154
+ const kb = getKeybindings();
155
155
 
156
- if (kb.matches(data, "selectCancel")) {
156
+ if (kb.matches(data, "tui.select.cancel")) {
157
157
  this.#cancel();
158
158
  return;
159
159
  }
@@ -19,6 +19,7 @@ import { analyzeAuthError, discoverOAuthEndpoints } from "../../mcp/oauth-discov
19
19
  import type { MCPHttpServerConfig, MCPServerConfig, MCPSseServerConfig, MCPStdioServerConfig } from "../../mcp/types";
20
20
  import { shortenPath } from "../../tools/render-utils";
21
21
  import { theme } from "../theme/theme";
22
+ import { matchesAppInterrupt } from "../utils/keybinding-matchers";
22
23
  import { DynamicBorder } from "./dynamic-border";
23
24
 
24
25
  type TransportType = "stdio" | "http" | "sse";
@@ -452,7 +453,7 @@ export class MCPAddWizard extends Container {
452
453
  }
453
454
 
454
455
  // Handle Escape (always handled by wizard)
455
- if (matchesKey(keyData, "escape")) {
456
+ if (matchesAppInterrupt(keyData)) {
456
457
  if (this.#currentStep === "name") {
457
458
  // Cancel wizard
458
459
  this.#onCancelCallback();
@@ -1,6 +1,17 @@
1
1
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import { getSupportedEfforts, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
3
- import { Container, Input, matchesKey, Spacer, type Tab, TabBar, Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
3
+ import {
4
+ Container,
5
+ getKeybindings,
6
+ Input,
7
+ matchesKey,
8
+ Spacer,
9
+ type Tab,
10
+ TabBar,
11
+ Text,
12
+ type TUI,
13
+ visibleWidth,
14
+ } from "@oh-my-pi/pi-tui";
4
15
  import { MODEL_ROLE_IDS, MODEL_ROLES, type ModelRegistry, type ModelRole } from "../../config/model-registry";
5
16
  import { resolveModelRoleValue } from "../../config/model-resolver";
6
17
  import type { Settings } from "../../config/settings";
@@ -647,7 +658,7 @@ export class ModelSelectorComponent extends Container {
647
658
  }
648
659
 
649
660
  // Escape or Ctrl+C - close selector
650
- if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
661
+ if (getKeybindings().matches(keyData, "tui.select.cancel")) {
651
662
  this.#onCancelCallback();
652
663
  return;
653
664
  }
@@ -698,7 +709,7 @@ export class ModelSelectorComponent extends Container {
698
709
  return;
699
710
  }
700
711
 
701
- if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
712
+ if (getKeybindings().matches(keyData, "tui.select.cancel")) {
702
713
  if (this.#menuStep === "thinking" && this.#menuSelectedRole !== null) {
703
714
  this.#menuStep = "role";
704
715
  const roleIndex = MENU_ROLE_ACTIONS.findIndex(action => action.role === this.#menuSelectedRole);
@@ -1,6 +1,7 @@
1
1
  import { getOAuthProviders, type OAuthProviderInfo } from "@oh-my-pi/pi-ai";
2
2
  import { Container, matchesKey, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
3
3
  import { theme } from "../../modes/theme/theme";
4
+ import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
4
5
  import type { AuthStorage } from "../../session/auth-storage";
5
6
  import { DynamicBorder } from "./dynamic-border";
6
7
  /**
@@ -202,7 +203,7 @@ export class OAuthSelectorComponent extends Container {
202
203
  }
203
204
  }
204
205
  // Escape or Ctrl+C
205
- else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
206
+ else if (matchesSelectCancel(keyData)) {
206
207
  this.stopValidation();
207
208
  this.#onCancelCallback();
208
209
  }
@@ -72,9 +72,8 @@ export class PythonExecutionComponent extends Container {
72
72
  }
73
73
 
74
74
  appendOutput(chunk: string): void {
75
- const clean = sanitizeText(chunk);
76
-
77
- const newLines = clean.split("\n").map(line => this.#clampDisplayLine(line));
75
+ // Chunk is pre-sanitized by OutputSink.push() — no need to sanitize again.
76
+ const newLines = chunk.split("\n").map(line => this.#clampDisplayLine(line));
78
77
  if (this.#outputLines.length > 0 && newLines.length > 0) {
79
78
  this.#outputLines[this.#outputLines.length - 1] = this.#clampDisplayLine(
80
79
  `${this.#outputLines[this.#outputLines.length - 1]}${newLines[0]}`,
@@ -11,6 +11,7 @@ import {
11
11
  visibleWidth,
12
12
  } from "@oh-my-pi/pi-tui";
13
13
  import { theme } from "../../modes/theme/theme";
14
+ import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
14
15
  import type { SessionInfo } from "../../session/session-manager";
15
16
  import { fuzzyFilter } from "../../utils/fuzzy";
16
17
  import { DynamicBorder } from "./dynamic-border";
@@ -219,7 +220,7 @@ class SessionList implements Component {
219
220
  return;
220
221
  }
221
222
  // Escape - cancel
222
- if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
223
+ if (matchesAppInterrupt(keyData)) {
223
224
  if (this.onCancel) {
224
225
  this.onCancel();
225
226
  }
@@ -21,6 +21,7 @@ import type {
21
21
  } from "../../config/settings-schema";
22
22
  import { SETTING_TABS, TAB_METADATA } from "../../config/settings-schema";
23
23
  import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme";
24
+ import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
24
25
  import { getTabBarTheme } from "../shared";
25
26
  import { DynamicBorder } from "./dynamic-border";
26
27
  import { PluginSettingsComponent } from "./plugin-settings";
@@ -521,7 +522,7 @@ export class SettingsSelectorComponent extends Container {
521
522
  }
522
523
 
523
524
  // Escape at top level cancels
524
- if ((matchesKey(data, "escape") || matchesKey(data, "esc")) && !this.#currentSubmenu) {
525
+ if (matchesAppInterrupt(data) && !this.#currentSubmenu) {
525
526
  this.callbacks.onCancel();
526
527
  return;
527
528
  }
@@ -11,6 +11,7 @@
11
11
  import { Container, matchesKey, padding } from "@oh-my-pi/pi-tui";
12
12
  import type { StatusLineSegmentId } from "../../config/settings-schema";
13
13
  import { theme } from "../../modes/theme/theme";
14
+ import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
14
15
  import { ALL_SEGMENT_IDS } from "./status-line/segments";
15
16
 
16
17
  // Segment display names and short descriptions
@@ -239,7 +240,7 @@ export class StatusLineSegmentEditorComponent extends Container {
239
240
  const left = this.#getSegmentsForColumn("left").map(s => s.id);
240
241
  const right = this.#getSegmentsForColumn("right").map(s => s.id);
241
242
  this.callbacks.onSave(left, right);
242
- } else if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
243
+ } else if (matchesAppInterrupt(data)) {
243
244
  this.callbacks.onCancel();
244
245
  }
245
246
  }
@@ -105,17 +105,16 @@ export class ToolExecutionComponent extends Container {
105
105
  // Cached converted images for Kitty protocol (which requires PNG), keyed by index
106
106
  #convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
107
107
  // Spinner animation for partial task results
108
- #spinnerFrame = 0;
108
+ #spinnerFrame?: number;
109
109
  #spinnerInterval?: NodeJS.Timeout;
110
110
  // Track if args are still being streamed (for edit/write spinner)
111
111
  #argsComplete = false;
112
112
  #renderState: {
113
- spinnerFrame: number;
113
+ spinnerFrame?: number;
114
114
  expanded: boolean;
115
115
  isPartial: boolean;
116
116
  renderContext?: Record<string, unknown>;
117
117
  } = {
118
- spinnerFrame: 0,
119
118
  expanded: false,
120
119
  isPartial: true,
121
120
  };
@@ -328,10 +327,9 @@ export class ToolExecutionComponent extends Container {
328
327
  this.#spinnerInterval = setInterval(() => {
329
328
  const frameCount = theme.spinnerFrames.length;
330
329
  if (frameCount === 0) return;
331
- this.#spinnerFrame = (this.#spinnerFrame + 1) % frameCount;
330
+ this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
332
331
  this.#renderState.spinnerFrame = this.#spinnerFrame;
333
332
  this.#ui.requestRender();
334
- // NO updateDisplay() — existing component closures read from renderState
335
333
  }, 80);
336
334
  } else if (!needsSpinner && this.#spinnerInterval) {
337
335
  clearInterval(this.#spinnerInterval);
@@ -346,6 +344,7 @@ export class ToolExecutionComponent extends Container {
346
344
  if (this.#spinnerInterval) {
347
345
  clearInterval(this.#spinnerInterval);
348
346
  this.#spinnerInterval = undefined;
347
+ this.#spinnerFrame = undefined;
349
348
  }
350
349
  }
351
350
 
@@ -12,6 +12,7 @@ import {
12
12
  } from "@oh-my-pi/pi-tui";
13
13
  import type { TreeFilterMode } from "../../config/settings-schema";
14
14
  import { theme } from "../../modes/theme/theme";
15
+ import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
15
16
  import type { SessionTreeNode } from "../../session/session-manager";
16
17
  import { shortenPath } from "../../tools/render-utils";
17
18
  import { DynamicBorder } from "./dynamic-border";
@@ -702,7 +703,7 @@ class TreeList implements Component {
702
703
  if (selected && this.onSelect) {
703
704
  this.onSelect(selected.node.entry.id);
704
705
  }
705
- } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
706
+ } else if (matchesAppInterrupt(keyData)) {
706
707
  if (this.#searchQuery) {
707
708
  this.#searchQuery = "";
708
709
  this.#applyFilter();
@@ -807,7 +808,7 @@ class LabelInput implements Component {
807
808
  if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
808
809
  const value = this.#input.getValue().trim();
809
810
  this.onSubmit?.(this.entryId, value || undefined);
810
- } else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
811
+ } else if (matchesAppInterrupt(keyData)) {
811
812
  this.onCancel?.();
812
813
  } else {
813
814
  this.#input.handleInput(keyData);