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

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 (40) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +3 -1
  3. package/package.json +7 -7
  4. package/src/capability/mcp.ts +2 -0
  5. package/src/config/keybindings.ts +0 -4
  6. package/src/config/mcp-schema.json +230 -0
  7. package/src/config/model-registry.ts +10 -3
  8. package/src/config/settings-schema.ts +13 -0
  9. package/src/discovery/builtin.ts +1 -0
  10. package/src/discovery/helpers.ts +9 -2
  11. package/src/discovery/mcp-json.ts +3 -0
  12. package/src/extensibility/custom-tools/types.ts +4 -0
  13. package/src/extensibility/extensions/types.ts +4 -0
  14. package/src/internal-urls/docs-index.generated.ts +2 -1
  15. package/src/mcp/client.ts +72 -17
  16. package/src/mcp/config-writer.ts +9 -2
  17. package/src/mcp/config.ts +1 -0
  18. package/src/mcp/discoverable-tool-metadata.ts +10 -0
  19. package/src/mcp/manager.ts +284 -57
  20. package/src/mcp/tool-bridge.ts +189 -106
  21. package/src/mcp/transports/http.ts +154 -29
  22. package/src/mcp/transports/stdio.ts +62 -12
  23. package/src/mcp/types.ts +22 -1
  24. package/src/modes/components/custom-editor.ts +126 -71
  25. package/src/modes/controllers/command-controller.ts +3 -12
  26. package/src/modes/controllers/extension-ui-controller.ts +3 -1
  27. package/src/modes/controllers/input-controller.ts +36 -37
  28. package/src/modes/controllers/mcp-command-controller.ts +45 -0
  29. package/src/modes/controllers/selector-controller.ts +11 -0
  30. package/src/modes/interactive-mode.ts +14 -17
  31. package/src/modes/utils/hotkeys-markdown.ts +24 -22
  32. package/src/patch/index.ts +36 -7
  33. package/src/prompts/agents/explore.md +4 -67
  34. package/src/sdk.ts +19 -2
  35. package/src/session/agent-session.ts +44 -21
  36. package/src/session/compaction/compaction.ts +1 -1
  37. package/src/slash-commands/builtin-registry.ts +1 -0
  38. package/src/system-prompt.ts +26 -14
  39. package/src/tools/write.ts +26 -2
  40. package/src/utils/title-generator.ts +46 -3
@@ -7,7 +7,16 @@
7
7
 
8
8
  import { getProjectDir, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
9
9
  import { type Subprocess, spawn } from "bun";
10
- import type { JsonRpcResponse, MCPRequestOptions, MCPStdioServerConfig, MCPTransport } from "../../mcp/types";
10
+ import type {
11
+ JsonRpcError,
12
+ JsonRpcMessage,
13
+ JsonRpcRequest,
14
+ JsonRpcResponse,
15
+ MCPRequestOptions,
16
+ MCPStdioServerConfig,
17
+ MCPTransport,
18
+ } from "../../mcp/types";
19
+ import { toJsonRpcError } from "../../mcp/types";
11
20
 
12
21
  /**
13
22
  * Stdio transport for MCP servers.
@@ -28,6 +37,7 @@ export class StdioTransport implements MCPTransport {
28
37
  onClose?: () => void;
29
38
  onError?: (error: Error) => void;
30
39
  onNotification?: (method: string, params: unknown) => void;
40
+ onRequest?: (method: string, params: unknown) => Promise<unknown>;
31
41
 
32
42
  constructor(private config: MCPStdioServerConfig) {}
33
43
 
@@ -71,7 +81,7 @@ export class StdioTransport implements MCPTransport {
71
81
  for await (const line of readJsonl(this.#process.stdout)) {
72
82
  if (!this.#connected) break;
73
83
  try {
74
- this.#handleMessage(line as JsonRpcResponse);
84
+ this.#handleMessage(line as JsonRpcMessage);
75
85
  } catch {
76
86
  // Skip malformed lines
77
87
  }
@@ -109,25 +119,65 @@ export class StdioTransport implements MCPTransport {
109
119
  }
110
120
  }
111
121
 
112
- #handleMessage(message: JsonRpcResponse): void {
113
- // Check if it's a response (has id)
114
- if ("id" in message && message.id !== null) {
115
- const pending = this.#pendingRequests.get(message.id);
122
+ #handleMessage(message: JsonRpcMessage | JsonRpcMessage[]): void {
123
+ if (Array.isArray(message)) {
124
+ for (const m of message) this.#handleMessage(m);
125
+ return;
126
+ }
127
+ // Server-to-client request: has both method and id
128
+ if ("method" in message && "id" in message && message.id != null) {
129
+ void this.#handleServerRequest(message as JsonRpcRequest);
130
+ return;
131
+ }
132
+
133
+ // Response to our request: has id
134
+ if ("id" in message && message.id != null) {
135
+ const response = message as JsonRpcResponse;
136
+ const pending = this.#pendingRequests.get(response.id);
116
137
  if (pending) {
117
- this.#pendingRequests.delete(message.id);
118
- if (message.error) {
119
- pending.reject(new Error(`MCP error ${message.error.code}: ${message.error.message}`));
138
+ this.#pendingRequests.delete(response.id);
139
+ if (response.error) {
140
+ pending.reject(new Error(`MCP error ${response.error.code}: ${response.error.message}`));
120
141
  } else {
121
- pending.resolve(message.result);
142
+ pending.resolve(response.result);
122
143
  }
123
144
  }
124
- } else if ("method" in message) {
125
- // It's a notification from server
145
+ return;
146
+ }
147
+
148
+ // Notification: has method but no id
149
+ if ("method" in message) {
126
150
  const notification = message as { method: string; params?: unknown };
127
151
  this.onNotification?.(notification.method, notification.params);
128
152
  }
129
153
  }
130
154
 
155
+ async #handleServerRequest(request: JsonRpcRequest): Promise<void> {
156
+ try {
157
+ if (!this.onRequest) {
158
+ this.#sendResponse(request.id, undefined, { code: -32601, message: "Method not found" });
159
+ return;
160
+ }
161
+ const result = await this.onRequest(request.method, request.params);
162
+ this.#sendResponse(request.id, result);
163
+ } catch (error) {
164
+ try {
165
+ this.#sendResponse(request.id, undefined, toJsonRpcError(error));
166
+ } catch {
167
+ // Best-effort — process may have exited
168
+ }
169
+ }
170
+ }
171
+
172
+ #sendResponse(id: string | number, result?: unknown, error?: JsonRpcError): void {
173
+ if (!this.#connected || !this.#process?.stdin) return;
174
+ const response = error
175
+ ? { jsonrpc: "2.0" as const, id, error }
176
+ : { jsonrpc: "2.0" as const, id, result: result ?? {} };
177
+ this.#process.stdin.write(`${JSON.stringify(response)}\n`);
178
+ this.#process.stdin.flush();
179
+ }
180
+
131
181
  #handleClose(): void {
132
182
  if (!this.#connected) return;
133
183
  this.#connected = false;
package/src/mcp/types.ts CHANGED
@@ -100,8 +100,12 @@ export interface MCPSseServerConfig extends MCPServerConfigBase {
100
100
 
101
101
  export type MCPServerConfig = MCPStdioServerConfig | MCPHttpServerConfig | MCPSseServerConfig;
102
102
 
103
- /** Root .mcp.json file structure */
103
+ export const MCP_CONFIG_SCHEMA_URL =
104
+ "https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json";
105
+
106
+ /** Root mcp.json/.mcp.json file structure */
104
107
  export interface MCPConfigFile {
108
+ $schema?: string;
105
109
  mcpServers?: Record<string, MCPServerConfig>;
106
110
  disabledServers?: string[];
107
111
  }
@@ -228,6 +232,8 @@ export interface MCPTransport {
228
232
  onClose?: () => void;
229
233
  onError?: (error: Error) => void;
230
234
  onNotification?: (method: string, params: unknown) => void;
235
+ /** Handler for server-to-client requests (e.g. roots/list). Returns result or throws a JsonRpcError. */
236
+ onRequest?: (method: string, params: unknown) => Promise<unknown>;
231
237
  }
232
238
 
233
239
  /** Transport factory function */
@@ -400,3 +406,18 @@ export const MCPNotificationMethods = {
400
406
  RESOURCES_UPDATED: "notifications/resources/updated",
401
407
  PROMPTS_LIST_CHANGED: "notifications/prompts/list_changed",
402
408
  } as const;
409
+
410
+ /** Extract a JsonRpcError from a thrown value. Preserves `.code` and `.message` from Error instances or plain objects. */
411
+ export function toJsonRpcError(error: unknown): JsonRpcError {
412
+ if (error instanceof Error) {
413
+ const code = "code" in error && typeof error.code === "number" ? error.code : -32603;
414
+ return { code, message: error.message };
415
+ }
416
+ if (typeof error === "object" && error !== null) {
417
+ const obj = error as Record<string, unknown>;
418
+ if (typeof obj.code === "number" && typeof obj.message === "string") {
419
+ return { code: obj.code, message: obj.message };
420
+ }
421
+ }
422
+ return { code: -32603, message: "Internal error" };
423
+ }
@@ -1,34 +1,89 @@
1
1
  import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
2
+ import type { AppAction } from "../../config/keybindings";
3
+
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"
21
+ >;
22
+
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"],
39
+ };
2
40
 
3
41
  /**
4
- * Custom editor that handles Escape and Ctrl+C keys for coding-agent
42
+ * Custom editor that handles configurable app-level shortcuts for coding-agent.
5
43
  */
6
44
  export class CustomEditor extends Editor {
7
45
  onEscape?: () => void;
8
46
  shouldBypassAutocompleteOnEscape?: () => boolean;
9
- onCtrlC?: () => void;
10
- onCtrlD?: () => void;
11
- onShiftTab?: () => void;
12
- onCtrlP?: () => void;
13
- onShiftCtrlP?: () => void;
14
- onCtrlL?: () => void;
15
- onCtrlR?: () => void;
16
- onCtrlO?: () => void;
17
- onCtrlT?: () => void;
18
- onCtrlG?: () => void;
19
- onCtrlZ?: () => void;
20
- onQuestionMark?: () => void;
21
- onCapsLock?: () => void;
22
- onAltP?: () => void;
23
- /** Called when Alt+Shift+C is pressed to copy prompt to clipboard. */
47
+ onClear?: () => void;
48
+ onExit?: () => void;
49
+ onCycleThinkingLevel?: () => void;
50
+ onCycleModelForward?: () => void;
51
+ onCycleModelBackward?: () => void;
52
+ onSelectModel?: () => void;
53
+ onExpandTools?: () => void;
54
+ onToggleThinking?: () => void;
55
+ onExternalEditor?: () => void;
56
+ onHistorySearch?: () => void;
57
+ onSuspend?: () => void;
58
+ onShowHotkeys?: () => void;
59
+ onQuickSelectModel?: () => void;
60
+ /** Called when the configured copy-prompt shortcut is pressed. */
24
61
  onCopyPrompt?: () => void;
25
- /** Called when Ctrl+V is pressed. Returns true if handled (image found), false to fall through to text paste. */
26
- onCtrlV?: () => Promise<boolean>;
27
- /** Called when Alt+Up is pressed (dequeue keybinding). */
28
- onAltUp?: () => void;
62
+ /** Called when the configured image-paste shortcut is pressed. */
63
+ onPasteImage?: () => Promise<boolean>;
64
+ /** Called when the configured dequeue shortcut is pressed. */
65
+ onDequeue?: () => void;
66
+ /** Called when Caps Lock is pressed. */
67
+ onCapsLock?: () => void;
29
68
 
30
- /** Custom key handlers from extensions */
69
+ /** Custom key handlers from extensions and non-built-in app actions. */
31
70
  #customKeyHandlers = new Map<KeyId, () => void>();
71
+ #actionKeys = new Map<ConfigurableEditorAction, KeyId[]>(
72
+ Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [action as ConfigurableEditorAction, [...keys]]),
73
+ );
74
+
75
+ setActionKeys(action: ConfigurableEditorAction, keys: KeyId[]): void {
76
+ this.#actionKeys.set(action, [...keys]);
77
+ }
78
+
79
+ #matchesAction(data: string, action: ConfigurableEditorAction): boolean {
80
+ const keys = this.#actionKeys.get(action);
81
+ if (!keys) return false;
82
+ for (const key of keys) {
83
+ if (matchesKey(data, key)) return true;
84
+ }
85
+ return false;
86
+ }
32
87
 
33
88
  /**
34
89
  * Register a custom key handler. Extensions use this for shortcuts.
@@ -59,111 +114,111 @@ export class CustomEditor extends Editor {
59
114
  return;
60
115
  }
61
116
 
62
- // Intercept Ctrl+V for image paste (async - fires and handles result)
63
- if (matchesKey(data, "ctrl+v") && this.onCtrlV) {
64
- void this.onCtrlV();
117
+ // Intercept configured image paste (async - fires and handles result)
118
+ if (this.#matchesAction(data, "pasteImage") && this.onPasteImage) {
119
+ void this.onPasteImage();
65
120
  return;
66
121
  }
67
122
 
68
- // Intercept Ctrl+G for external editor
69
- if (matchesKey(data, "ctrl+g") && this.onCtrlG) {
70
- this.onCtrlG();
123
+ // Intercept configured external editor shortcut
124
+ if (this.#matchesAction(data, "externalEditor") && this.onExternalEditor) {
125
+ this.onExternalEditor();
71
126
  return;
72
127
  }
73
128
 
74
129
  // Intercept Alt+P for quick model switching
75
- if (matchesKey(data, "alt+p") && this.onAltP) {
76
- this.onAltP();
130
+ if (matchesKey(data, "alt+p") && this.onQuickSelectModel) {
131
+ this.onQuickSelectModel();
77
132
  return;
78
133
  }
79
134
 
80
- // Intercept Ctrl+Z for suspend
81
- if (matchesKey(data, "ctrl+z") && this.onCtrlZ) {
82
- this.onCtrlZ();
135
+ // Intercept configured suspend shortcut
136
+ if (this.#matchesAction(data, "suspend") && this.onSuspend) {
137
+ this.onSuspend();
83
138
  return;
84
139
  }
85
140
 
86
- // Intercept Ctrl+T for thinking block visibility toggle
87
- if (matchesKey(data, "ctrl+t") && this.onCtrlT) {
88
- this.onCtrlT();
141
+ // Intercept configured thinking block visibility toggle
142
+ if (this.#matchesAction(data, "toggleThinking") && this.onToggleThinking) {
143
+ this.onToggleThinking();
89
144
  return;
90
145
  }
91
146
 
92
- // Intercept Ctrl+L for model selector
93
- if (matchesKey(data, "ctrl+l") && this.onCtrlL) {
94
- this.onCtrlL();
147
+ // Intercept configured model selector shortcut
148
+ if (this.#matchesAction(data, "selectModel") && this.onSelectModel) {
149
+ this.onSelectModel();
95
150
  return;
96
151
  }
97
152
 
98
- // Intercept Ctrl+R for history search
99
- if (matchesKey(data, "ctrl+r") && this.onCtrlR) {
100
- this.onCtrlR();
153
+ // Intercept configured history search shortcut
154
+ if (this.#matchesAction(data, "historySearch") && this.onHistorySearch) {
155
+ this.onHistorySearch();
101
156
  return;
102
157
  }
103
158
 
104
- // Intercept Ctrl+O for tool output expansion
105
- if (matchesKey(data, "ctrl+o") && this.onCtrlO) {
106
- this.onCtrlO();
159
+ // Intercept configured tool output expansion shortcut
160
+ if (this.#matchesAction(data, "expandTools") && this.onExpandTools) {
161
+ this.onExpandTools();
107
162
  return;
108
163
  }
109
164
 
110
- // Intercept Shift+Ctrl+P for backward model cycling (check before Ctrl+P)
111
- if ((matchesKey(data, "shift+ctrl+p") || matchesKey(data, "ctrl+shift+p")) && this.onShiftCtrlP) {
112
- this.onShiftCtrlP();
165
+ // Intercept configured backward model cycling (check before forward cycling)
166
+ if (this.#matchesAction(data, "cycleModelBackward") && this.onCycleModelBackward) {
167
+ this.onCycleModelBackward();
113
168
  return;
114
169
  }
115
170
 
116
- // Intercept Ctrl+P for model cycling
117
- if (matchesKey(data, "ctrl+p") && this.onCtrlP) {
118
- this.onCtrlP();
171
+ // Intercept configured forward model cycling
172
+ if (this.#matchesAction(data, "cycleModelForward") && this.onCycleModelForward) {
173
+ this.onCycleModelForward();
119
174
  return;
120
175
  }
121
176
 
122
- // Intercept Shift+Tab for thinking level cycling
123
- if (matchesKey(data, "shift+tab") && this.onShiftTab) {
124
- this.onShiftTab();
177
+ // Intercept configured thinking level cycling
178
+ if (this.#matchesAction(data, "cycleThinkingLevel") && this.onCycleThinkingLevel) {
179
+ this.onCycleThinkingLevel();
125
180
  return;
126
181
  }
127
182
 
128
- // Intercept Escape key.
129
- // Default behavior keeps autocomplete dismissal, but parent can prioritize global escape handling.
130
- if ((matchesKey(data, "escape") || matchesKey(data, "esc")) && this.onEscape) {
183
+ // Intercept configured interrupt shortcut.
184
+ // Default behavior keeps autocomplete dismissal, but parent can prioritize global interrupt handling.
185
+ if (this.#matchesAction(data, "interrupt") && this.onEscape) {
131
186
  if (!this.isShowingAutocomplete() || this.shouldBypassAutocompleteOnEscape?.()) {
132
187
  this.onEscape();
133
188
  return;
134
189
  }
135
190
  }
136
191
 
137
- // Intercept Ctrl+C
138
- if (matchesKey(data, "ctrl+c") && this.onCtrlC) {
139
- this.onCtrlC();
192
+ // Intercept configured clear shortcut
193
+ if (this.#matchesAction(data, "clear") && this.onClear) {
194
+ this.onClear();
140
195
  return;
141
196
  }
142
197
 
143
- // Intercept Ctrl+D (only when editor is empty)
144
- if (matchesKey(data, "ctrl+d")) {
145
- if (this.getText().length === 0 && this.onCtrlD) {
146
- this.onCtrlD();
198
+ // Intercept configured exit shortcut (only when editor is empty)
199
+ if (this.#matchesAction(data, "exit")) {
200
+ if (this.getText().length === 0 && this.onExit) {
201
+ this.onExit();
147
202
  }
148
- // Always consume Ctrl+D (don't pass to parent)
203
+ // Always consume exit shortcut (don't pass to parent)
149
204
  return;
150
205
  }
151
206
 
152
- // Intercept Alt+Up for dequeue (restore queued message to editor)
153
- if (matchesKey(data, "alt+up") && this.onAltUp) {
154
- this.onAltUp();
207
+ // Intercept configured dequeue shortcut (restore queued message to editor)
208
+ if (this.#matchesAction(data, "dequeue") && this.onDequeue) {
209
+ this.onDequeue();
155
210
  return;
156
211
  }
157
212
 
158
- // Intercept Alt+Shift+C to copy prompt to clipboard
159
- if (matchesKey(data, "alt+shift+c") && this.onCopyPrompt) {
213
+ // Intercept configured copy-prompt shortcut
214
+ if (this.#matchesAction(data, "copyPrompt") && this.onCopyPrompt) {
160
215
  this.onCopyPrompt();
161
216
  return;
162
217
  }
163
218
 
164
219
  // Intercept ? when editor is empty to show hotkeys
165
- if (data === "?" && this.getText().length === 0 && this.onQuestionMark) {
166
- this.onQuestionMark();
220
+ if (data === "?" && this.getText().length === 0 && this.onShowHotkeys) {
221
+ this.onShowHotkeys();
167
222
  return;
168
223
  }
169
224
 
@@ -32,6 +32,7 @@ import { resolveToCwd, stripOuterDoubleQuotes } from "../../tools/path-utils";
32
32
  import { replaceTabs } from "../../tools/render-utils";
33
33
  import { getChangelogPath, parseChangelog } from "../../utils/changelog";
34
34
  import { openPath } from "../../utils/open";
35
+ import { setSessionTerminalTitle } from "../../utils/title-generator";
35
36
 
36
37
  export class CommandController {
37
38
  constructor(private readonly ctx: InteractiveModeContext) {}
@@ -505,18 +506,7 @@ export class CommandController {
505
506
  }
506
507
 
507
508
  handleHotkeysCommand(): void {
508
- const expandToolsKey = this.ctx.keybindings.getDisplayString("expandTools") || "Ctrl+O";
509
- const planModeKey = this.ctx.keybindings.getDisplayString("togglePlanMode") || "Alt+Shift+P";
510
- const sttKey = this.ctx.keybindings.getDisplayString("toggleSTT") || "Alt+H";
511
- const copyLineKey = this.ctx.keybindings.getDisplayString("copyLine") || "Alt+Shift+L";
512
- const copyPromptKey = this.ctx.keybindings.getDisplayString("copyPrompt") || "Alt+Shift+C";
513
- const hotkeys = buildHotkeysMarkdown({
514
- expandToolsKey,
515
- planModeKey,
516
- sttKey,
517
- copyLineKey,
518
- copyPromptKey,
519
- });
509
+ const hotkeys = buildHotkeysMarkdown({ keybindings: this.ctx.keybindings });
520
510
  this.ctx.chatContainer.addChild(new Spacer(1));
521
511
  this.ctx.chatContainer.addChild(new DynamicBorder());
522
512
  this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0));
@@ -585,6 +575,7 @@ export class CommandController {
585
575
  }
586
576
  }
587
577
  await this.ctx.session.newSession();
578
+ setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
588
579
 
589
580
  this.ctx.statusLine.invalidate();
590
581
  this.ctx.statusLine.setSessionStartTime(Date.now());
@@ -16,7 +16,7 @@ import { HookInputComponent } from "../../modes/components/hook-input";
16
16
  import { HookSelectorComponent } from "../../modes/components/hook-selector";
17
17
  import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme";
18
18
  import type { InteractiveModeContext } from "../../modes/types";
19
- import { setTerminalTitle } from "../../utils/title-generator";
19
+ import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
20
20
 
21
21
  export class ExtensionUiController {
22
22
  #extensionTerminalInputUnsubscribers = new Set<() => void>();
@@ -154,6 +154,7 @@ export class ExtensionUiController {
154
154
  if (!success) {
155
155
  return { cancelled: true };
156
156
  }
157
+ setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
157
158
 
158
159
  // Call setup callback if provided
159
160
  if (options?.setup) {
@@ -230,6 +231,7 @@ export class ExtensionUiController {
230
231
  if (!result) {
231
232
  return { cancelled: true };
232
233
  }
234
+ setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
233
235
  this.ctx.chatContainer.clear();
234
236
  this.ctx.renderInitialMessages();
235
237
  await this.ctx.reloadTodos();
@@ -12,7 +12,7 @@ import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../sessio
12
12
  import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
13
13
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
14
14
  import { resizeImage } from "../../utils/image-resize";
15
- import { generateSessionTitle, setTerminalTitle } from "../../utils/title-generator";
15
+ import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
16
16
 
17
17
  interface Expandable {
18
18
  setExpanded(expanded: boolean): void;
@@ -26,6 +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
30
  this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
30
31
  Boolean(
31
32
  this.ctx.loadingAnimation ||
@@ -64,7 +65,7 @@ export class InputController {
64
65
  } else if (this.ctx.session.isStreaming) {
65
66
  void this.ctx.session.abort();
66
67
  } else if (!this.ctx.editor.getText().trim()) {
67
- // Double-escape with empty editor triggers /tree, /branch, or nothing based on setting
68
+ // Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting
68
69
  const action = settings.get("doubleEscapeAction");
69
70
  if (action !== "none") {
70
71
  const now = Date.now();
@@ -82,42 +83,44 @@ export class InputController {
82
83
  }
83
84
  };
84
85
 
85
- this.ctx.editor.onCtrlC = () => this.handleCtrlC();
86
- this.ctx.editor.onCtrlD = () => this.handleCtrlD();
87
- this.ctx.editor.onCtrlZ = () => this.handleCtrlZ();
88
- this.ctx.editor.onShiftTab = () => this.cycleThinkingLevel();
89
- this.ctx.editor.onCtrlP = () => this.cycleRoleModel();
90
- this.ctx.editor.onShiftCtrlP = () => this.cycleRoleModel({ temporary: true });
91
- this.ctx.editor.onAltP = () => this.ctx.showModelSelector({ temporaryOnly: true });
86
+ this.ctx.editor.setActionKeys("clear", this.ctx.keybindings.getKeys("clear"));
87
+ this.ctx.editor.onClear = () => this.handleCtrlC();
88
+ this.ctx.editor.setActionKeys("exit", this.ctx.keybindings.getKeys("exit"));
89
+ this.ctx.editor.onExit = () => this.handleCtrlD();
90
+ this.ctx.editor.setActionKeys("suspend", this.ctx.keybindings.getKeys("suspend"));
91
+ this.ctx.editor.onSuspend = () => this.handleCtrlZ();
92
+ this.ctx.editor.setActionKeys("cycleThinkingLevel", this.ctx.keybindings.getKeys("cycleThinkingLevel"));
93
+ this.ctx.editor.onCycleThinkingLevel = () => this.cycleThinkingLevel();
94
+ this.ctx.editor.setActionKeys("cycleModelForward", this.ctx.keybindings.getKeys("cycleModelForward"));
95
+ this.ctx.editor.onCycleModelForward = () => this.cycleRoleModel();
96
+ this.ctx.editor.setActionKeys("cycleModelBackward", this.ctx.keybindings.getKeys("cycleModelBackward"));
97
+ this.ctx.editor.onCycleModelBackward = () => this.cycleRoleModel({ temporary: true });
98
+ this.ctx.editor.onQuickSelectModel = () => this.ctx.showModelSelector({ temporaryOnly: true });
92
99
 
93
100
  // Global debug handler on TUI (works regardless of focus)
94
101
  this.ctx.ui.onDebug = () => this.ctx.showDebugSelector();
95
- this.ctx.editor.onCtrlL = () => this.ctx.showModelSelector();
96
- this.ctx.editor.onCtrlR = () => this.ctx.showHistorySearch();
97
- this.ctx.editor.onCtrlT = () => this.ctx.toggleTodoExpansion();
98
- this.ctx.editor.onCtrlG = () => void this.openExternalEditor();
99
- this.ctx.editor.onQuestionMark = () => this.ctx.handleHotkeysCommand();
100
- this.ctx.editor.onCtrlV = () => this.handleImagePaste();
101
- const copyPromptKeys = this.ctx.keybindings.getKeys("copyPrompt");
102
- this.ctx.editor.onCopyPrompt = copyPromptKeys.includes("alt+shift+c") ? () => this.handleCopyPrompt() : undefined;
103
-
102
+ this.ctx.editor.setActionKeys("selectModel", this.ctx.keybindings.getKeys("selectModel"));
103
+ this.ctx.editor.onSelectModel = () => this.ctx.showModelSelector();
104
+ this.ctx.editor.setActionKeys("historySearch", this.ctx.keybindings.getKeys("historySearch"));
105
+ this.ctx.editor.onHistorySearch = () => this.ctx.showHistorySearch();
106
+ this.ctx.editor.setActionKeys("toggleThinking", this.ctx.keybindings.getKeys("toggleThinking"));
107
+ this.ctx.editor.onToggleThinking = () => this.ctx.toggleThinkingBlockVisibility();
108
+ this.ctx.editor.setActionKeys("externalEditor", this.ctx.keybindings.getKeys("externalEditor"));
109
+ this.ctx.editor.onExternalEditor = () => void this.openExternalEditor();
110
+ this.ctx.editor.onShowHotkeys = () => this.ctx.handleHotkeysCommand();
111
+ this.ctx.editor.setActionKeys("pasteImage", this.ctx.keybindings.getKeys("pasteImage"));
112
+ this.ctx.editor.onPasteImage = () => this.handleImagePaste();
113
+ this.ctx.editor.setActionKeys("copyPrompt", this.ctx.keybindings.getKeys("copyPrompt"));
114
+ this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt();
115
+ this.ctx.editor.setActionKeys("expandTools", this.ctx.keybindings.getKeys("expandTools"));
116
+ this.ctx.editor.onExpandTools = () => this.toggleToolOutputExpansion();
117
+ this.ctx.editor.setActionKeys("dequeue", this.ctx.keybindings.getKeys("dequeue"));
118
+ this.ctx.editor.onDequeue = () => this.handleDequeue();
119
+
120
+ this.ctx.editor.clearCustomKeyHandlers();
104
121
  // Wire up extension shortcuts
105
122
  this.registerExtensionShortcuts();
106
123
 
107
- const expandToolsKeys = this.ctx.keybindings.getKeys("expandTools");
108
- this.ctx.editor.onCtrlO = expandToolsKeys.includes("ctrl+o") ? () => this.toggleToolOutputExpansion() : undefined;
109
- for (const key of expandToolsKeys) {
110
- if (key === "ctrl+o") continue;
111
- this.ctx.editor.setCustomKeyHandler(key, () => this.toggleToolOutputExpansion());
112
- }
113
-
114
- const dequeueKeys = this.ctx.keybindings.getKeys("dequeue");
115
- this.ctx.editor.onAltUp = dequeueKeys.includes("alt+up") ? () => this.handleDequeue() : undefined;
116
- for (const key of dequeueKeys) {
117
- if (key === "alt+up") continue;
118
- this.ctx.editor.setCustomKeyHandler(key, () => this.handleDequeue());
119
- }
120
-
121
124
  const planModeKeys = this.ctx.keybindings.getKeys("togglePlanMode");
122
125
  for (const key of planModeKeys) {
123
126
  this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand());
@@ -144,10 +147,6 @@ export class InputController {
144
147
  for (const key of this.ctx.keybindings.getKeys("copyLine")) {
145
148
  this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
146
149
  }
147
- for (const key of copyPromptKeys) {
148
- if (key === "alt+shift+c") continue;
149
- this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyPrompt());
150
- }
151
150
 
152
151
  this.ctx.editor.onChange = (text: string) => {
153
152
  const wasBashMode = this.ctx.isBashMode;
@@ -326,7 +325,7 @@ export class InputController {
326
325
  .then(async title => {
327
326
  if (title) {
328
327
  await this.ctx.sessionManager.setSessionName(title);
329
- setTerminalTitle(`π: ${title}`);
328
+ setSessionTerminalTitle(title, this.ctx.sessionManager.getCwd());
330
329
  }
331
330
  })
332
331
  .catch(() => {});