@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,5 +1,6 @@
1
1
  import { type Component, Container, matchesKey, Spacer, Text, truncateToWidth } from "@oh-my-pi/pi-tui";
2
2
  import { theme } from "../../modes/theme/theme";
3
+ import { matchesSelectCancel } from "../../modes/utils/keybinding-matchers";
3
4
  import { DynamicBorder } from "./dynamic-border";
4
5
 
5
6
  interface UserMessageItem {
@@ -91,14 +92,8 @@ class UserMessageList implements Component {
91
92
  this.onSelect(selected.id);
92
93
  }
93
94
  }
94
- // Escape - cancel
95
- else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) {
96
- if (this.onCancel) {
97
- this.onCancel();
98
- }
99
- }
100
- // Ctrl+C - cancel
101
- else if (matchesKey(keyData, "ctrl+c")) {
95
+ // Escape / cancel
96
+ else if (matchesSelectCancel(keyData)) {
102
97
  if (this.onCancel) {
103
98
  this.onCancel();
104
99
  }
@@ -1,6 +1,11 @@
1
1
  import { Container, Markdown, Spacer } from "@oh-my-pi/pi-tui";
2
2
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
3
3
 
4
+ // OSC 133 shell integration: marks prompt zones for terminal multiplexers
5
+ const OSC133_ZONE_START = "\x1b]133;A\x07";
6
+ const OSC133_ZONE_END = "\x1b]133;B\x07";
7
+ const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
8
+
4
9
  /**
5
10
  * Component that renders a user message
6
11
  */
@@ -19,4 +24,15 @@ export class UserMessageComponent extends Container {
19
24
  }),
20
25
  );
21
26
  }
27
+
28
+ override render(width: number): string[] {
29
+ const lines = super.render(width);
30
+ if (lines.length === 0) {
31
+ return lines;
32
+ }
33
+
34
+ lines[0] = OSC133_ZONE_START + lines[0];
35
+ lines[lines.length - 1] = lines[lines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
36
+ return lines;
37
+ }
22
38
  }
@@ -690,7 +690,6 @@ export class CommandController {
690
690
  chunk => {
691
691
  if (this.ctx.bashComponent) {
692
692
  this.ctx.bashComponent.appendOutput(chunk);
693
- this.ctx.ui.requestRender();
694
693
  }
695
694
  },
696
695
  { excludeFromContext },
@@ -732,7 +731,6 @@ export class CommandController {
732
731
  chunk => {
733
732
  if (this.ctx.pythonComponent) {
734
733
  this.ctx.pythonComponent.appendOutput(chunk);
735
- this.ctx.ui.requestRender();
736
734
  }
737
735
  },
738
736
  { excludeFromContext },
@@ -1,5 +1,5 @@
1
1
  import type { Component, OverlayHandle, TUI } from "@oh-my-pi/pi-tui";
2
- import { Spacer, Text } from "@oh-my-pi/pi-tui";
2
+ import { Container, Spacer, Text } from "@oh-my-pi/pi-tui";
3
3
  import { logger } from "@oh-my-pi/pi-utils";
4
4
  import { KeybindingsManager } from "../../config/keybindings";
5
5
  import type {
@@ -9,6 +9,9 @@ import type {
9
9
  ExtensionError,
10
10
  ExtensionUIContext,
11
11
  ExtensionUIDialogOptions,
12
+ ExtensionUiComponent,
13
+ ExtensionWidgetContent,
14
+ ExtensionWidgetOptions,
12
15
  TerminalInputHandler,
13
16
  } from "../../extensibility/extensions";
14
17
  import { HookEditorComponent } from "../../modes/components/hook-editor";
@@ -18,8 +21,12 @@ import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, them
18
21
  import type { InteractiveModeContext } from "../../modes/types";
19
22
  import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
20
23
 
24
+ const MAX_WIDGET_LINES = 10;
25
+
21
26
  export class ExtensionUiController {
22
27
  #extensionTerminalInputUnsubscribers = new Set<() => void>();
28
+ #hookWidgetsAbove = new Map<string, ExtensionUiComponent>();
29
+ #hookWidgetsBelow = new Map<string, ExtensionUiComponent>();
23
30
  constructor(private ctx: InteractiveModeContext) {}
24
31
 
25
32
  /**
@@ -35,7 +42,7 @@ export class ExtensionUiController {
35
42
  onTerminalInput: handler => this.addExtensionTerminalInputListener(handler),
36
43
  setStatus: (key, text) => this.setHookStatus(key, text),
37
44
  setWorkingMessage: message => this.ctx.setWorkingMessage(message),
38
- setWidget: (key, content) => this.setHookWidget(key, content),
45
+ setWidget: (key, content, options) => this.setHookWidget(key, content, options),
39
46
  setTitle: title => setTerminalTitle(title),
40
47
  custom: (factory, options) => this.showHookCustom(factory, options),
41
48
  setEditorText: text => this.ctx.editor.setText(text),
@@ -150,6 +157,7 @@ export class ExtensionUiController {
150
157
 
151
158
  // Create new session
152
159
  this.clearExtensionTerminalInputListeners();
160
+ this.clearHookWidgets();
153
161
  const success = await this.ctx.session.newSession({ parentSession: options?.parentSession });
154
162
  if (!success) {
155
163
  return { cancelled: true };
@@ -227,6 +235,7 @@ export class ExtensionUiController {
227
235
  await this.ctx.executeCompaction(instructionsOrOptions, false);
228
236
  },
229
237
  switchSession: async sessionPath => {
238
+ this.clearHookWidgets();
230
239
  const result = await this.ctx.session.switchSession(sessionPath);
231
240
  if (!result) {
232
241
  return { cancelled: true };
@@ -252,11 +261,73 @@ export class ExtensionUiController {
252
261
  });
253
262
  }
254
263
 
255
- setHookWidget(key: string, content: unknown): void {
256
- this.ctx.statusLine.setHookStatus(key, String(content));
264
+ setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void {
265
+ const placement = options?.placement ?? "aboveEditor";
266
+ this.#removeHookWidget(this.#hookWidgetsAbove, key);
267
+ this.#removeHookWidget(this.#hookWidgetsBelow, key);
268
+
269
+ if (content === undefined) {
270
+ this.#rebuildHookWidgets();
271
+ return;
272
+ }
273
+
274
+ const target = placement === "belowEditor" ? this.#hookWidgetsBelow : this.#hookWidgetsAbove;
275
+ target.set(key, this.#createHookWidget(content));
276
+ this.#rebuildHookWidgets();
277
+ }
278
+
279
+ #removeHookWidget(widgets: Map<string, ExtensionUiComponent>, key: string): void {
280
+ const existing = widgets.get(key);
281
+ existing?.dispose?.();
282
+ widgets.delete(key);
283
+ }
284
+
285
+ #createHookWidget(content: ExtensionWidgetContent): ExtensionUiComponent {
286
+ if (Array.isArray(content)) {
287
+ const container = new Container();
288
+ for (const line of content.slice(0, MAX_WIDGET_LINES)) {
289
+ container.addChild(new Text(line, 1, 0));
290
+ }
291
+ if (content.length > MAX_WIDGET_LINES) {
292
+ container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0));
293
+ }
294
+ return container;
295
+ }
296
+ if (content === undefined) {
297
+ throw new Error("Widget content missing");
298
+ }
299
+ return content(this.ctx.ui, theme);
300
+ }
301
+
302
+ #rebuildHookWidgets(): void {
303
+ this.#renderHookWidgetContainer(this.ctx.hookWidgetContainerAbove, this.#hookWidgetsAbove, true, true);
304
+ this.#renderHookWidgetContainer(this.ctx.hookWidgetContainerBelow, this.#hookWidgetsBelow, false, false);
257
305
  this.ctx.ui.requestRender();
258
306
  }
259
307
 
308
+ #renderHookWidgetContainer(
309
+ container: Container,
310
+ widgets: Map<string, ExtensionUiComponent>,
311
+ spacerWhenEmpty: boolean,
312
+ leadingSpacer: boolean,
313
+ ): void {
314
+ container.clear();
315
+
316
+ if (widgets.size === 0) {
317
+ if (spacerWhenEmpty) {
318
+ container.addChild(new Spacer(1));
319
+ }
320
+ return;
321
+ }
322
+
323
+ if (leadingSpacer) {
324
+ container.addChild(new Spacer(1));
325
+ }
326
+ for (const widget of widgets.values()) {
327
+ container.addChild(widget);
328
+ }
329
+ }
330
+
260
331
  initializeHookRunner(uiContext: ExtensionUIContext, _hasUI: boolean): void {
261
332
  const extensionRunner = this.ctx.session.extensionRunner;
262
333
  if (!extensionRunner) {
@@ -353,6 +424,7 @@ export class ExtensionUiController {
353
424
 
354
425
  // Create new session
355
426
  this.clearExtensionTerminalInputListeners();
427
+ this.clearHookWidgets();
356
428
  const success = await this.ctx.session.newSession({ parentSession: options?.parentSession });
357
429
  if (!success) {
358
430
  return { cancelled: true };
@@ -432,6 +504,7 @@ export class ExtensionUiController {
432
504
  if (this.ctx.isBackgrounded) {
433
505
  return { cancelled: true };
434
506
  }
507
+ this.clearHookWidgets();
435
508
  const result = await this.ctx.session.switchSession(sessionPath);
436
509
  if (!result) {
437
510
  return { cancelled: true };
@@ -828,6 +901,18 @@ export class ExtensionUiController {
828
901
  };
829
902
  }
830
903
 
904
+ clearHookWidgets(): void {
905
+ for (const widget of this.#hookWidgetsAbove.values()) {
906
+ widget.dispose?.();
907
+ }
908
+ for (const widget of this.#hookWidgetsBelow.values()) {
909
+ widget.dispose?.();
910
+ }
911
+ this.#hookWidgetsAbove.clear();
912
+ this.#hookWidgetsBelow.clear();
913
+ this.#rebuildHookWidgets();
914
+ }
915
+
831
916
  clearExtensionTerminalInputListeners(): void {
832
917
  for (const unsubscribe of this.#extensionTerminalInputUnsubscribers) {
833
918
  unsubscribe();
@@ -26,7 +26,7 @@ export class InputController {
26
26
  constructor(private ctx: InteractiveModeContext) {}
27
27
 
28
28
  setupKeyHandlers(): void {
29
- this.ctx.editor.setActionKeys("interrupt", this.ctx.keybindings.getKeys("interrupt"));
29
+ this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
30
30
  this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
31
31
  Boolean(
32
32
  this.ctx.loadingAnimation ||
@@ -83,68 +83,74 @@ export class InputController {
83
83
  }
84
84
  };
85
85
 
86
- this.ctx.editor.setActionKeys("clear", this.ctx.keybindings.getKeys("clear"));
86
+ this.ctx.editor.setActionKeys("app.clear", this.ctx.keybindings.getKeys("app.clear"));
87
87
  this.ctx.editor.onClear = () => this.handleCtrlC();
88
- this.ctx.editor.setActionKeys("exit", this.ctx.keybindings.getKeys("exit"));
88
+ this.ctx.editor.setActionKeys("app.exit", this.ctx.keybindings.getKeys("app.exit"));
89
89
  this.ctx.editor.onExit = () => this.handleCtrlD();
90
- this.ctx.editor.setActionKeys("suspend", this.ctx.keybindings.getKeys("suspend"));
90
+ this.ctx.editor.setActionKeys("app.suspend", this.ctx.keybindings.getKeys("app.suspend"));
91
91
  this.ctx.editor.onSuspend = () => this.handleCtrlZ();
92
- this.ctx.editor.setActionKeys("cycleThinkingLevel", this.ctx.keybindings.getKeys("cycleThinkingLevel"));
92
+ this.ctx.editor.setActionKeys("app.thinking.cycle", this.ctx.keybindings.getKeys("app.thinking.cycle"));
93
93
  this.ctx.editor.onCycleThinkingLevel = () => this.cycleThinkingLevel();
94
- this.ctx.editor.setActionKeys("cycleModelForward", this.ctx.keybindings.getKeys("cycleModelForward"));
94
+ this.ctx.editor.setActionKeys("app.model.cycleForward", this.ctx.keybindings.getKeys("app.model.cycleForward"));
95
95
  this.ctx.editor.onCycleModelForward = () => this.cycleRoleModel();
96
- this.ctx.editor.setActionKeys("cycleModelBackward", this.ctx.keybindings.getKeys("cycleModelBackward"));
96
+ this.ctx.editor.setActionKeys("app.model.cycleBackward", this.ctx.keybindings.getKeys("app.model.cycleBackward"));
97
97
  this.ctx.editor.onCycleModelBackward = () => this.cycleRoleModel({ temporary: true });
98
98
  this.ctx.editor.onQuickSelectModel = () => this.ctx.showModelSelector({ temporaryOnly: true });
99
99
 
100
100
  // Global debug handler on TUI (works regardless of focus)
101
101
  this.ctx.ui.onDebug = () => this.ctx.showDebugSelector();
102
- this.ctx.editor.setActionKeys("selectModel", this.ctx.keybindings.getKeys("selectModel"));
102
+ this.ctx.editor.setActionKeys("app.model.select", this.ctx.keybindings.getKeys("app.model.select"));
103
103
  this.ctx.editor.onSelectModel = () => this.ctx.showModelSelector();
104
- this.ctx.editor.setActionKeys("historySearch", this.ctx.keybindings.getKeys("historySearch"));
104
+ this.ctx.editor.setActionKeys("app.history.search", this.ctx.keybindings.getKeys("app.history.search"));
105
105
  this.ctx.editor.onHistorySearch = () => this.ctx.showHistorySearch();
106
- this.ctx.editor.setActionKeys("toggleThinking", this.ctx.keybindings.getKeys("toggleThinking"));
106
+ this.ctx.editor.setActionKeys("app.thinking.toggle", this.ctx.keybindings.getKeys("app.thinking.toggle"));
107
107
  this.ctx.editor.onToggleThinking = () => this.ctx.toggleThinkingBlockVisibility();
108
- this.ctx.editor.setActionKeys("externalEditor", this.ctx.keybindings.getKeys("externalEditor"));
108
+ this.ctx.editor.setActionKeys("app.editor.external", this.ctx.keybindings.getKeys("app.editor.external"));
109
109
  this.ctx.editor.onExternalEditor = () => void this.openExternalEditor();
110
110
  this.ctx.editor.onShowHotkeys = () => this.ctx.handleHotkeysCommand();
111
- this.ctx.editor.setActionKeys("pasteImage", this.ctx.keybindings.getKeys("pasteImage"));
111
+ this.ctx.editor.setActionKeys(
112
+ "app.clipboard.pasteImage",
113
+ this.ctx.keybindings.getKeys("app.clipboard.pasteImage"),
114
+ );
112
115
  this.ctx.editor.onPasteImage = () => this.handleImagePaste();
113
- this.ctx.editor.setActionKeys("copyPrompt", this.ctx.keybindings.getKeys("copyPrompt"));
116
+ this.ctx.editor.setActionKeys(
117
+ "app.clipboard.copyPrompt",
118
+ this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"),
119
+ );
114
120
  this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt();
115
- this.ctx.editor.setActionKeys("expandTools", this.ctx.keybindings.getKeys("expandTools"));
121
+ this.ctx.editor.setActionKeys("app.tools.expand", this.ctx.keybindings.getKeys("app.tools.expand"));
116
122
  this.ctx.editor.onExpandTools = () => this.toggleToolOutputExpansion();
117
- this.ctx.editor.setActionKeys("dequeue", this.ctx.keybindings.getKeys("dequeue"));
123
+ this.ctx.editor.setActionKeys("app.message.dequeue", this.ctx.keybindings.getKeys("app.message.dequeue"));
118
124
  this.ctx.editor.onDequeue = () => this.handleDequeue();
119
125
 
120
126
  this.ctx.editor.clearCustomKeyHandlers();
121
127
  // Wire up extension shortcuts
122
128
  this.registerExtensionShortcuts();
123
129
 
124
- const planModeKeys = this.ctx.keybindings.getKeys("togglePlanMode");
130
+ const planModeKeys = this.ctx.keybindings.getKeys("app.plan.toggle");
125
131
  for (const key of planModeKeys) {
126
132
  this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand());
127
133
  }
128
134
 
129
- for (const key of this.ctx.keybindings.getKeys("newSession")) {
135
+ for (const key of this.ctx.keybindings.getKeys("app.session.new")) {
130
136
  this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.handleClearCommand());
131
137
  }
132
- for (const key of this.ctx.keybindings.getKeys("tree")) {
138
+ for (const key of this.ctx.keybindings.getKeys("app.session.tree")) {
133
139
  this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showTreeSelector());
134
140
  }
135
- for (const key of this.ctx.keybindings.getKeys("fork")) {
141
+ for (const key of this.ctx.keybindings.getKeys("app.session.fork")) {
136
142
  this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showUserMessageSelector());
137
143
  }
138
- for (const key of this.ctx.keybindings.getKeys("resume")) {
144
+ for (const key of this.ctx.keybindings.getKeys("app.session.resume")) {
139
145
  this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionSelector());
140
146
  }
141
- for (const key of this.ctx.keybindings.getKeys("followUp")) {
147
+ for (const key of this.ctx.keybindings.getKeys("app.message.followUp")) {
142
148
  this.ctx.editor.setCustomKeyHandler(key, () => void this.handleFollowUp());
143
149
  }
144
- for (const key of this.ctx.keybindings.getKeys("toggleSTT")) {
150
+ for (const key of this.ctx.keybindings.getKeys("app.stt.toggle")) {
145
151
  this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
146
152
  }
147
- for (const key of this.ctx.keybindings.getKeys("copyLine")) {
153
+ for (const key of this.ctx.keybindings.getKeys("app.clipboard.copyLine")) {
148
154
  this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
149
155
  }
150
156
 
@@ -660,7 +660,7 @@ export class MCPCommandController {
660
660
  }
661
661
 
662
662
  async #removeManagedOAuthCredential(credentialId: string | undefined): Promise<void> {
663
- if (!credentialId || !credentialId.startsWith("mcp_oauth_")) return;
663
+ if (!credentialId?.startsWith("mcp_oauth_")) return;
664
664
  await this.ctx.session.modelRegistry.authStorage.remove(credentialId);
665
665
  }
666
666
 
@@ -4,6 +4,7 @@ import { postmortem } from "@oh-my-pi/pi-utils";
4
4
  /**
5
5
  * Run modes for the coding agent.
6
6
  */
7
+ export { runAcpMode } from "./acp";
7
8
  export { InteractiveMode, type InteractiveModeOptions } from "./interactive-mode";
8
9
  export { type PrintModeOptions, runPrintMode } from "./print-mode";
9
10
  export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client";
@@ -12,7 +12,12 @@ import chalk from "chalk";
12
12
  import { KeybindingsManager } from "../config/keybindings";
13
13
  import { renderPromptTemplate } from "../config/prompt-templates";
14
14
  import { type Settings, settings } from "../config/settings";
15
- import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../extensibility/extensions";
15
+ import type {
16
+ ExtensionUIContext,
17
+ ExtensionUIDialogOptions,
18
+ ExtensionWidgetContent,
19
+ ExtensionWidgetOptions,
20
+ } from "../extensibility/extensions";
16
21
  import type { CompactOptions } from "../extensibility/extensions/types";
17
22
  import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slash-commands";
18
23
  import { resolveLocalUrlToPath } from "../internal-urls";
@@ -93,6 +98,8 @@ export class InteractiveMode implements InteractiveModeContext {
93
98
  btwContainer: Container;
94
99
  editor: CustomEditor;
95
100
  editorContainer: Container;
101
+ hookWidgetContainerAbove: Container;
102
+ hookWidgetContainerBelow: Container;
96
103
  statusLine: StatusLineComponent;
97
104
 
98
105
  isInitialized = false;
@@ -216,6 +223,9 @@ export class InteractiveMode implements InteractiveModeContext {
216
223
  } catch (error) {
217
224
  logger.warn("History storage unavailable", { error: String(error) });
218
225
  }
226
+ this.hookWidgetContainerAbove = new Container();
227
+ this.hookWidgetContainerAbove.addChild(new Spacer(1));
228
+ this.hookWidgetContainerBelow = new Container();
219
229
  this.editorContainer = new Container();
220
230
  this.editorContainer.addChild(this.editor);
221
231
  this.statusLine = new StatusLineComponent(session);
@@ -263,7 +273,7 @@ export class InteractiveMode implements InteractiveModeContext {
263
273
  async init(): Promise<void> {
264
274
  if (this.isInitialized) return;
265
275
 
266
- this.keybindings = await logger.timeAsync("InteractiveMode.init:keybindings", () => KeybindingsManager.create());
276
+ this.keybindings = logger.time("InteractiveMode.init:keybindings", () => KeybindingsManager.create());
267
277
 
268
278
  // Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP)
269
279
  this.#cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush());
@@ -329,8 +339,9 @@ export class InteractiveMode implements InteractiveModeContext {
329
339
  this.ui.addChild(this.todoContainer);
330
340
  this.ui.addChild(this.btwContainer);
331
341
  this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border)
332
- this.ui.addChild(new Spacer(1));
342
+ this.ui.addChild(this.hookWidgetContainerAbove);
333
343
  this.ui.addChild(this.editorContainer);
344
+ this.ui.addChild(this.hookWidgetContainerBelow);
334
345
  this.ui.setFocus(this.editor);
335
346
 
336
347
  this.#inputController.setupKeyHandlers();
@@ -837,6 +848,7 @@ export class InteractiveMode implements InteractiveModeContext {
837
848
  this.#sttController = undefined;
838
849
  }
839
850
  this.#extensionUiController.clearExtensionTerminalInputListeners();
851
+ this.#extensionUiController.clearHookWidgets();
840
852
  this.statusLine.dispose();
841
853
  if (this.#resizeHandler) {
842
854
  process.stdout.removeListener("resize", this.#resizeHandler);
@@ -1359,8 +1371,8 @@ export class InteractiveMode implements InteractiveModeContext {
1359
1371
  return this.#extensionUiController.emitCustomToolSessionEvent(reason, previousSessionFile);
1360
1372
  }
1361
1373
 
1362
- setHookWidget(key: string, content: unknown): void {
1363
- this.#extensionUiController.setHookWidget(key, content);
1374
+ setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void {
1375
+ this.#extensionUiController.setHookWidget(key, content, options);
1364
1376
  }
1365
1377
 
1366
1378
  setHookStatus(key: string, text: string | undefined): void {
@@ -146,7 +146,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
146
146
  });
147
147
 
148
148
  // Send initial message with attachments
149
- if (initialMessage) {
149
+ if (initialMessage !== undefined) {
150
150
  await session.prompt(initialMessage, { images: initialImages });
151
151
  }
152
152
 
@@ -2,7 +2,7 @@ import {
2
2
  type AutocompleteItem,
3
3
  type AutocompleteProvider,
4
4
  CombinedAutocompleteProvider,
5
- getEditorKeybindings,
5
+ getKeybindings,
6
6
  type SlashCommand,
7
7
  } from "@oh-my-pi/pi-tui";
8
8
  import { formatKeyHints, type KeybindingsManager } from "../config/keybindings";
@@ -174,26 +174,26 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
174
174
  export function createPromptActionAutocompleteProvider(
175
175
  options: PromptActionAutocompleteOptions,
176
176
  ): PromptActionAutocompleteProvider {
177
- const editorKeybindings = getEditorKeybindings();
177
+ const editorKeybindings = getKeybindings();
178
178
  const actions: PromptActionDefinition[] = [
179
179
  {
180
180
  id: "copy-line",
181
181
  label: "Copy current line",
182
- description: formatKeyHints(options.keybindings.getKeys("copyLine")),
182
+ description: formatKeyHints(options.keybindings.getKeys("app.clipboard.copyLine")),
183
183
  keywords: ["copy", "line", "clipboard", "current"],
184
184
  execute: options.copyCurrentLine,
185
185
  },
186
186
  {
187
187
  id: "copy-prompt",
188
188
  label: "Copy whole prompt",
189
- description: formatKeyHints(options.keybindings.getKeys("copyPrompt")),
189
+ description: formatKeyHints(options.keybindings.getKeys("app.clipboard.copyPrompt")),
190
190
  keywords: ["copy", "prompt", "clipboard", "message"],
191
191
  execute: options.copyPrompt,
192
192
  },
193
193
  {
194
194
  id: "undo",
195
195
  label: "Undo",
196
- description: formatKeyHints(editorKeybindings.getKeys("undo")),
196
+ description: formatKeyHints(editorKeybindings.getKeys("tui.editor.undo")),
197
197
  keywords: ["undo", "revert", "edit", "history"],
198
198
  execute: options.undo,
199
199
  },
@@ -214,14 +214,14 @@ export function createPromptActionAutocompleteProvider(
214
214
  {
215
215
  id: "cursor-line-start",
216
216
  label: "Move cursor to beginning of line",
217
- description: formatKeyHints(editorKeybindings.getKeys("cursorLineStart")),
217
+ description: formatKeyHints(editorKeybindings.getKeys("tui.editor.cursorLineStart")),
218
218
  keywords: ["move", "cursor", "line", "start", "beginning", "home"],
219
219
  execute: options.moveCursorToLineStart,
220
220
  },
221
221
  {
222
222
  id: "cursor-line-end",
223
223
  label: "Move cursor to end of line",
224
- description: formatKeyHints(editorKeybindings.getKeys("cursorLineEnd")),
224
+ description: formatKeyHints(editorKeybindings.getKeys("tui.editor.cursorLineEnd")),
225
225
  keywords: ["move", "cursor", "line", "end"],
226
226
  execute: options.moveCursorToLineEnd,
227
227
  },
@@ -11,7 +11,11 @@
11
11
  * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
12
12
  */
13
13
  import { readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
14
- import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../extensibility/extensions";
14
+ import type {
15
+ ExtensionUIContext,
16
+ ExtensionUIDialogOptions,
17
+ ExtensionWidgetOptions,
18
+ } from "../../extensibility/extensions";
15
19
  import { type Theme, theme } from "../../modes/theme/theme";
16
20
  import type { AgentSession } from "../../session/agent-session";
17
21
  import type {
@@ -198,7 +202,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
198
202
  // Not supported in RPC mode
199
203
  }
200
204
 
201
- setWidget(key: string, content: unknown): void {
205
+ setWidget(key: string, content: unknown, options?: ExtensionWidgetOptions): void {
202
206
  // Only support string arrays in RPC mode - factory functions are ignored
203
207
  if (content === undefined || Array.isArray(content)) {
204
208
  this.output({
@@ -207,6 +211,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
207
211
  method: "setWidget",
208
212
  widgetKey: key,
209
213
  widgetLines: content as string[] | undefined,
214
+ widgetPlacement: options?.placement,
210
215
  } as RpcExtensionUIRequest);
211
216
  }
212
217
  // Component factories are not supported in RPC mode - would need TUI access
@@ -215,6 +215,7 @@ export type RpcExtensionUIRequest =
215
215
  method: "setWidget";
216
216
  widgetKey: string;
217
217
  widgetLines: string[] | undefined;
218
+ widgetPlacement?: "aboveEditor" | "belowEditor";
218
219
  }
219
220
  | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
220
221
  | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };
@@ -1679,6 +1679,7 @@ export function getCurrentThemeName(): string | undefined {
1679
1679
  var currentSymbolPresetOverride: SymbolPreset | undefined;
1680
1680
  var currentColorBlindMode: boolean = false;
1681
1681
  var themeWatcher: fs.FSWatcher | undefined;
1682
+ var themeReloadTimer: NodeJS.Timeout | undefined;
1682
1683
  var sigwinchHandler: (() => void) | undefined;
1683
1684
  var autoDetectedTheme: boolean = false;
1684
1685
  var autoDarkTheme: string = "dark";
@@ -1888,11 +1889,7 @@ export function isValidSymbolPreset(preset: string): preset is SymbolPreset {
1888
1889
  }
1889
1890
 
1890
1891
  async function startThemeWatcher(): Promise<void> {
1891
- // Stop existing watcher if any
1892
- if (themeWatcher) {
1893
- themeWatcher.close();
1894
- themeWatcher = undefined;
1895
- }
1892
+ stopThemeWatcher();
1896
1893
 
1897
1894
  // Only watch if it's a custom theme (not built-in)
1898
1895
  if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") {
@@ -1900,54 +1897,62 @@ async function startThemeWatcher(): Promise<void> {
1900
1897
  }
1901
1898
 
1902
1899
  const customThemesDir = getCustomThemesDir();
1903
- const themeFile = path.join(customThemesDir, `${currentThemeName}.json`);
1900
+ const watchedThemeName = currentThemeName;
1901
+ const watchedFileName = `${watchedThemeName}.json`;
1902
+ const themeFile = path.join(customThemesDir, watchedFileName);
1904
1903
 
1905
1904
  // Only watch if the file exists
1906
1905
  if (!fs.existsSync(themeFile)) {
1907
1906
  return;
1908
1907
  }
1909
1908
 
1910
- try {
1911
- themeWatcher = fs.watch(themeFile, eventType => {
1912
- if (eventType === "change") {
1913
- // Debounce rapid changes
1914
- setTimeout(() => {
1915
- loadTheme(currentThemeName!, getCurrentThemeOptions())
1916
- .then(loadedTheme => {
1917
- theme = loadedTheme;
1918
- if (onThemeChangeCallback) {
1919
- onThemeChangeCallback();
1920
- }
1921
- })
1922
- .catch(err => {
1923
- logger.debug("Theme reload error during file change", { error: String(err) });
1924
- });
1925
- }, 100);
1926
- } else if (eventType === "rename") {
1927
- // File was deleted or renamed - fall back to default theme
1928
- setTimeout(() => {
1929
- if (!fs.existsSync(themeFile)) {
1930
- currentThemeName = "dark";
1931
- loadTheme("dark", getCurrentThemeOptions())
1932
- .then(loadedTheme => {
1933
- theme = loadedTheme;
1934
- if (onThemeChangeCallback) {
1935
- onThemeChangeCallback();
1936
- }
1937
- })
1938
- .catch(err => {
1939
- logger.debug("Theme reload error during rename fallback", { error: String(err) });
1940
- });
1941
- if (themeWatcher) {
1942
- themeWatcher.close();
1943
- themeWatcher = undefined;
1944
- }
1909
+ const scheduleReload = () => {
1910
+ if (themeReloadTimer) {
1911
+ clearTimeout(themeReloadTimer);
1912
+ }
1913
+ themeReloadTimer = setTimeout(() => {
1914
+ themeReloadTimer = undefined;
1915
+
1916
+ // Ignore stale timers after switching themes or stopping the watcher
1917
+ if (currentThemeName !== watchedThemeName) {
1918
+ return;
1919
+ }
1920
+
1921
+ // Keep the last successfully loaded theme active if the file is temporarily missing
1922
+ if (!fs.existsSync(themeFile)) {
1923
+ return;
1924
+ }
1925
+
1926
+ loadTheme(watchedThemeName, getCurrentThemeOptions())
1927
+ .then(loadedTheme => {
1928
+ theme = loadedTheme;
1929
+ if (onThemeChangeCallback) {
1930
+ onThemeChangeCallback();
1945
1931
  }
1946
- }, 100);
1932
+ })
1933
+ .catch(() => {
1934
+ // Ignore errors (file might be in invalid state while being edited)
1935
+ });
1936
+ }, 100);
1937
+ };
1938
+
1939
+ try {
1940
+ themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => {
1941
+ if (currentThemeName !== watchedThemeName) {
1942
+ return;
1947
1943
  }
1944
+ if (!filename) {
1945
+ scheduleReload();
1946
+ return;
1947
+ }
1948
+ const changedFile = String(filename);
1949
+ if (changedFile !== watchedFileName) {
1950
+ return;
1951
+ }
1952
+ scheduleReload();
1948
1953
  });
1949
- } catch (err) {
1950
- logger.debug("Failed to start theme watcher", { error: String(err) });
1954
+ } catch {
1955
+ // Ignore errors starting watcher
1951
1956
  }
1952
1957
  }
1953
1958
 
@@ -2023,6 +2028,10 @@ function stopSigwinchListener(): void {
2023
2028
  }
2024
2029
 
2025
2030
  export function stopThemeWatcher(): void {
2031
+ if (themeReloadTimer) {
2032
+ clearTimeout(themeReloadTimer);
2033
+ themeReloadTimer = undefined;
2034
+ }
2026
2035
  if (themeWatcher) {
2027
2036
  themeWatcher.close();
2028
2037
  themeWatcher = undefined;