@oh-my-pi/pi-coding-agent 11.2.3 → 11.3.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 (92) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/examples/extensions/plan-mode.ts +1 -1
  3. package/examples/hooks/qna.ts +1 -1
  4. package/examples/hooks/status-line.ts +1 -1
  5. package/examples/sdk/11-sessions.ts +1 -1
  6. package/package.json +8 -8
  7. package/src/cli/args.ts +9 -6
  8. package/src/cli/update-cli.ts +2 -2
  9. package/src/commands/index/index.ts +2 -5
  10. package/src/commit/agentic/agent.ts +1 -1
  11. package/src/commit/changelog/index.ts +2 -2
  12. package/src/config/keybindings.ts +16 -1
  13. package/src/config/model-registry.ts +25 -20
  14. package/src/config/model-resolver.ts +8 -8
  15. package/src/config/resolve-config-value.ts +92 -0
  16. package/src/config/settings-schema.ts +9 -0
  17. package/src/config.ts +14 -1
  18. package/src/export/html/template.css +7 -0
  19. package/src/export/html/template.generated.ts +1 -1
  20. package/src/export/html/template.js +33 -16
  21. package/src/extensibility/custom-commands/bundled/review/index.ts +1 -1
  22. package/src/extensibility/extensions/index.ts +18 -0
  23. package/src/extensibility/extensions/loader.ts +15 -0
  24. package/src/extensibility/extensions/runner.ts +78 -1
  25. package/src/extensibility/extensions/types.ts +131 -5
  26. package/src/extensibility/extensions/wrapper.ts +1 -1
  27. package/src/extensibility/plugins/git-url.ts +270 -0
  28. package/src/extensibility/plugins/index.ts +2 -0
  29. package/src/extensibility/slash-commands.ts +45 -0
  30. package/src/index.ts +7 -0
  31. package/src/lsp/render.ts +50 -43
  32. package/src/lsp/utils.ts +2 -2
  33. package/src/main.ts +11 -10
  34. package/src/mcp/transports/stdio.ts +3 -5
  35. package/src/modes/components/custom-message.ts +0 -8
  36. package/src/modes/components/diff.ts +1 -7
  37. package/src/modes/components/footer.ts +4 -4
  38. package/src/modes/components/model-selector.ts +4 -0
  39. package/src/modes/components/todo-display.ts +13 -3
  40. package/src/modes/components/tool-execution.ts +30 -16
  41. package/src/modes/components/tree-selector.ts +50 -19
  42. package/src/modes/controllers/event-controller.ts +1 -0
  43. package/src/modes/controllers/extension-ui-controller.ts +34 -2
  44. package/src/modes/controllers/input-controller.ts +47 -33
  45. package/src/modes/controllers/selector-controller.ts +10 -15
  46. package/src/modes/interactive-mode.ts +50 -38
  47. package/src/modes/print-mode.ts +6 -0
  48. package/src/modes/rpc/rpc-client.ts +4 -4
  49. package/src/modes/rpc/rpc-mode.ts +17 -2
  50. package/src/modes/rpc/rpc-types.ts +2 -2
  51. package/src/modes/types.ts +1 -0
  52. package/src/modes/utils/ui-helpers.ts +3 -1
  53. package/src/patch/applicator.ts +2 -3
  54. package/src/patch/fuzzy.ts +1 -1
  55. package/src/patch/shared.ts +74 -61
  56. package/src/prompts/system/system-prompt.md +1 -0
  57. package/src/prompts/tools/task.md +6 -0
  58. package/src/sdk.ts +15 -11
  59. package/src/session/agent-session.ts +72 -23
  60. package/src/session/auth-storage.ts +2 -1
  61. package/src/session/blob-store.ts +105 -0
  62. package/src/session/session-manager.ts +107 -44
  63. package/src/task/executor.ts +19 -9
  64. package/src/task/render.ts +80 -58
  65. package/src/tools/ask.ts +28 -5
  66. package/src/tools/bash.ts +47 -39
  67. package/src/tools/browser.ts +248 -26
  68. package/src/tools/calculator.ts +42 -23
  69. package/src/tools/fetch.ts +33 -16
  70. package/src/tools/find.ts +57 -22
  71. package/src/tools/grep.ts +54 -25
  72. package/src/tools/index.ts +5 -5
  73. package/src/tools/notebook.ts +19 -6
  74. package/src/tools/path-utils.ts +26 -1
  75. package/src/tools/python.ts +20 -14
  76. package/src/tools/read.ts +21 -8
  77. package/src/tools/render-utils.ts +5 -45
  78. package/src/tools/ssh.ts +59 -53
  79. package/src/tools/submit-result.ts +2 -2
  80. package/src/tools/todo-write.ts +32 -14
  81. package/src/tools/truncate.ts +1 -1
  82. package/src/tools/write.ts +39 -24
  83. package/src/tui/output-block.ts +61 -3
  84. package/src/tui/tree-list.ts +4 -4
  85. package/src/tui/utils.ts +71 -1
  86. package/src/utils/frontmatter.ts +1 -1
  87. package/src/utils/title-generator.ts +1 -1
  88. package/src/utils/tools-manager.ts +18 -2
  89. package/src/web/scrapers/osv.ts +4 -1
  90. package/src/web/scrapers/youtube.ts +1 -1
  91. package/src/web/search/index.ts +1 -1
  92. package/src/web/search/render.ts +96 -90
@@ -55,6 +55,7 @@ class TreeList implements Component {
55
55
  private toolCallMap: Map<string, ToolCallInfo> = new Map();
56
56
  private multipleRoots = false;
57
57
  private activePathIds: Set<string> = new Set();
58
+ private lastSelectedId: string | null = null;
58
59
 
59
60
  public onSelect?: (entryId: string) => void;
60
61
  public onCancel?: () => void;
@@ -64,19 +65,17 @@ class TreeList implements Component {
64
65
  tree: SessionTreeNode[],
65
66
  private readonly currentLeafId: string | null,
66
67
  private readonly maxVisibleLines: number,
68
+ initialSelectedId?: string,
67
69
  ) {
68
70
  this.multipleRoots = tree.length > 1;
69
71
  this.flatNodes = this.flattenTree(tree);
70
72
  this.buildActivePath();
71
73
  this.applyFilter();
72
74
 
73
- // Start with current leaf selected
74
- const leafIndex = this.filteredNodes.findIndex(n => n.node.entry.id === currentLeafId);
75
- if (leafIndex !== -1) {
76
- this.selectedIndex = leafIndex;
77
- } else {
78
- this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
79
- }
75
+ // Start with initialSelectedId if provided, otherwise current leaf
76
+ const targetId = initialSelectedId ?? currentLeafId;
77
+ this.selectedIndex = this.findNearestVisibleIndex(targetId);
78
+ this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? null;
80
79
  }
81
80
 
82
81
  /** Build the set of entry IDs on the path from root to current leaf */
@@ -100,6 +99,36 @@ class TreeList implements Component {
100
99
  }
101
100
  }
102
101
 
102
+ /**
103
+ * Find the index of the nearest visible entry, walking up the parent chain if needed.
104
+ * Returns the index in filteredNodes, or the last index as fallback.
105
+ */
106
+ private findNearestVisibleIndex(entryId: string | null): number {
107
+ if (this.filteredNodes.length === 0) return 0;
108
+
109
+ // Build a map for parent lookup
110
+ const entryMap = new Map<string, FlatNode>();
111
+ for (const flatNode of this.flatNodes) {
112
+ entryMap.set(flatNode.node.entry.id, flatNode);
113
+ }
114
+
115
+ // Build a map of visible entry IDs to their indices in filteredNodes
116
+ const visibleIdToIndex = new Map<string, number>(this.filteredNodes.map((node, i) => [node.node.entry.id, i]));
117
+
118
+ // Walk from entryId up to root, looking for a visible entry
119
+ let currentId = entryId;
120
+ while (currentId !== null) {
121
+ const index = visibleIdToIndex.get(currentId);
122
+ if (index !== undefined) return index;
123
+ const node = entryMap.get(currentId);
124
+ if (!node) break;
125
+ currentId = node.node.entry.parentId ?? null;
126
+ }
127
+
128
+ // Fallback: last visible entry
129
+ return this.filteredNodes.length - 1;
130
+ }
131
+
103
132
  private flattenTree(roots: SessionTreeNode[]): FlatNode[] {
104
133
  const result: FlatNode[] = [];
105
134
  this.toolCallMap.clear();
@@ -231,8 +260,11 @@ class TreeList implements Component {
231
260
  }
232
261
 
233
262
  private applyFilter(): void {
234
- // Remember currently selected node to preserve cursor position
235
- const previouslySelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
263
+ // Update lastSelectedId only when we have a valid selection (non-empty list)
264
+ // This preserves the selection when switching through empty filter results
265
+ if (this.filteredNodes.length > 0) {
266
+ this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId;
267
+ }
236
268
 
237
269
  const searchTokens = this.searchQuery.toLowerCase().split(/\s+/).filter(Boolean);
238
270
 
@@ -295,18 +327,17 @@ class TreeList implements Component {
295
327
  return true;
296
328
  });
297
329
 
298
- // Try to preserve cursor on the same node after filtering
299
- if (previouslySelectedId) {
300
- const newIndex = this.filteredNodes.findIndex(n => n.node.entry.id === previouslySelectedId);
301
- if (newIndex !== -1) {
302
- this.selectedIndex = newIndex;
303
- return;
304
- }
330
+ // Try to preserve cursor on the same node, or find nearest visible ancestor
331
+ if (this.lastSelectedId) {
332
+ this.selectedIndex = this.findNearestVisibleIndex(this.lastSelectedId);
333
+ } else if (this.selectedIndex >= this.filteredNodes.length) {
334
+ // Clamp index if out of bounds
335
+ this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
305
336
  }
306
337
 
307
- // Fall back: clamp index if out of bounds
308
- if (this.selectedIndex >= this.filteredNodes.length) {
309
- this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
338
+ // Update lastSelectedId to the actual selection (may have changed due to parent walk)
339
+ if (this.filteredNodes.length > 0) {
340
+ this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId;
310
341
  }
311
342
  }
312
343
 
@@ -97,6 +97,7 @@ export class EventController {
97
97
  this.ctx.ui.requestRender();
98
98
  } else if (event.message.role === "assistant") {
99
99
  this.lastThinkingCount = 0;
100
+ this.resetReadGroup();
100
101
  this.ctx.streamingComponent = new AssistantMessageComponent(undefined, this.ctx.hideThinkingBlock);
101
102
  this.ctx.streamingMessage = event.message;
102
103
  this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
@@ -64,6 +64,8 @@ export class ExtensionUiController {
64
64
  setFooter: () => {},
65
65
  setHeader: () => {},
66
66
  setEditorComponent: () => {},
67
+ getToolsExpanded: () => this.ctx.toolOutputExpanded,
68
+ setToolsExpanded: expanded => this.ctx.setToolsExpanded(expanded),
67
69
  };
68
70
  this.ctx.setToolUIContext(uiContext, true);
69
71
 
@@ -114,6 +116,7 @@ export class ExtensionUiController {
114
116
  },
115
117
  getThinkingLevel: () => this.ctx.session.thinkingLevel,
116
118
  setThinkingLevel: level => this.ctx.session.setThinkingLevel(level),
119
+ getCommands: () => [],
117
120
  };
118
121
  const contextActions: ExtensionContextActions = {
119
122
  getModel: () => this.ctx.session.model,
@@ -130,6 +133,7 @@ export class ExtensionUiController {
130
133
  instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
131
134
  await this.ctx.session.compact(instructions, options);
132
135
  },
136
+ getSystemPrompt: () => this.ctx.session.systemPrompt,
133
137
  };
134
138
  const commandActions: ExtensionCommandContextActions = {
135
139
  getContextUsage: () => this.ctx.session.getContextUsage(),
@@ -195,7 +199,7 @@ export class ExtensionUiController {
195
199
  this.ctx.chatContainer.clear();
196
200
  this.ctx.renderInitialMessages();
197
201
  await this.ctx.reloadTodos();
198
- if (result.editorText) {
202
+ if (result.editorText && !this.ctx.editor.getText().trim()) {
199
203
  this.ctx.editor.setText(result.editorText);
200
204
  }
201
205
  this.ctx.showStatus("Navigated to selected point");
@@ -212,6 +216,16 @@ export class ExtensionUiController {
212
216
  }
213
217
  await this.ctx.executeCompaction(instructionsOrOptions, false);
214
218
  },
219
+ switchSession: async sessionPath => {
220
+ const result = await this.ctx.session.switchSession(sessionPath);
221
+ if (!result) {
222
+ return { cancelled: true };
223
+ }
224
+ this.ctx.chatContainer.clear();
225
+ this.ctx.renderInitialMessages();
226
+ await this.ctx.reloadTodos();
227
+ return { cancelled: false };
228
+ },
215
229
  };
216
230
 
217
231
  extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
@@ -283,6 +297,7 @@ export class ExtensionUiController {
283
297
  },
284
298
  getThinkingLevel: () => this.ctx.session.thinkingLevel,
285
299
  setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist),
300
+ getCommands: () => [],
286
301
  };
287
302
  const contextActions: ExtensionContextActions = {
288
303
  getModel: () => this.ctx.session.model,
@@ -299,6 +314,7 @@ export class ExtensionUiController {
299
314
  instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
300
315
  await this.ctx.session.compact(instructions, options);
301
316
  },
317
+ getSystemPrompt: () => this.ctx.session.systemPrompt,
302
318
  };
303
319
  const commandActions: ExtensionCommandContextActions = {
304
320
  getContextUsage: () => this.ctx.session.getContextUsage(),
@@ -373,7 +389,7 @@ export class ExtensionUiController {
373
389
  this.ctx.chatContainer.clear();
374
390
  this.ctx.renderInitialMessages();
375
391
  await this.ctx.reloadTodos();
376
- if (result.editorText) {
392
+ if (result.editorText && !this.ctx.editor.getText().trim()) {
377
393
  this.ctx.editor.setText(result.editorText);
378
394
  }
379
395
  this.ctx.showStatus("Navigated to selected point");
@@ -390,6 +406,19 @@ export class ExtensionUiController {
390
406
  }
391
407
  await this.ctx.executeCompaction(instructionsOrOptions, false);
392
408
  },
409
+ switchSession: async sessionPath => {
410
+ if (this.ctx.isBackgrounded) {
411
+ return { cancelled: true };
412
+ }
413
+ const result = await this.ctx.session.switchSession(sessionPath);
414
+ if (!result) {
415
+ return { cancelled: true };
416
+ }
417
+ this.ctx.chatContainer.clear();
418
+ this.ctx.renderInitialMessages();
419
+ await this.ctx.reloadTodos();
420
+ return { cancelled: false };
421
+ },
393
422
  };
394
423
 
395
424
  extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
@@ -418,6 +447,8 @@ export class ExtensionUiController {
418
447
  setFooter: () => {},
419
448
  setHeader: () => {},
420
449
  setEditorComponent: () => {},
450
+ getToolsExpanded: () => false,
451
+ setToolsExpanded: () => {},
421
452
  };
422
453
  }
423
454
 
@@ -461,6 +492,7 @@ export class ExtensionUiController {
461
492
  shutdown: () => {
462
493
  // Signal shutdown request
463
494
  },
495
+ getSystemPrompt: () => this.ctx.session.systemPrompt,
464
496
  });
465
497
  } catch (err) {
466
498
  this.showToolError(registeredTool.definition.name, err instanceof Error ? err.message : String(err));
@@ -97,6 +97,22 @@ export class InputController {
97
97
  this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand());
98
98
  }
99
99
 
100
+ for (const key of this.ctx.keybindings.getKeys("newSession")) {
101
+ this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.handleClearCommand());
102
+ }
103
+ for (const key of this.ctx.keybindings.getKeys("tree")) {
104
+ this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showTreeSelector());
105
+ }
106
+ for (const key of this.ctx.keybindings.getKeys("fork")) {
107
+ this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showUserMessageSelector());
108
+ }
109
+ for (const key of this.ctx.keybindings.getKeys("resume")) {
110
+ this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionSelector());
111
+ }
112
+ for (const key of this.ctx.keybindings.getKeys("followUp")) {
113
+ this.ctx.editor.setCustomKeyHandler(key, () => void this.handleFollowUp());
114
+ }
115
+
100
116
  this.ctx.editor.onChange = (text: string) => {
101
117
  const wasBashMode = this.ctx.isBashMode;
102
118
  const wasPythonMode = this.ctx.isPythonMode;
@@ -107,37 +123,6 @@ export class InputController {
107
123
  this.ctx.updateEditorBorderColor();
108
124
  }
109
125
  };
110
-
111
- this.ctx.editor.onAltEnter = async (text: string) => {
112
- const trimmedText = text.trim();
113
-
114
- // Queue follow-up messages while compaction is running
115
- if (this.ctx.session.isCompacting) {
116
- if (!trimmedText) {
117
- this.ctx.editor.handleInput("\n");
118
- return;
119
- }
120
- this.ctx.queueCompactionMessage(trimmedText, "followUp");
121
- return;
122
- }
123
-
124
- // Alt+Enter queues a follow-up message while streaming
125
- if (this.ctx.session.isStreaming) {
126
- if (!trimmedText) {
127
- this.ctx.editor.handleInput("\n");
128
- return;
129
- }
130
- this.ctx.editor.addToHistory(trimmedText);
131
- this.ctx.editor.setText("");
132
- await this.ctx.session.prompt(trimmedText, { streamingBehavior: "followUp" });
133
- this.ctx.updatePendingMessagesDisplay();
134
- this.ctx.ui.requestRender();
135
- return;
136
- }
137
-
138
- // Default behavior: insert a new line
139
- this.ctx.editor.handleInput("\n");
140
- };
141
126
  }
142
127
 
143
128
  setupEditorSubmitHandler(): void {
@@ -519,6 +504,31 @@ export class InputController {
519
504
  }
520
505
  }
521
506
 
507
+ /** Send editor text as a follow-up message (queued behind current stream). */
508
+ async handleFollowUp(): Promise<void> {
509
+ const text = this.ctx.editor.getText().trim();
510
+ if (!text) return;
511
+
512
+ if (this.ctx.session.isCompacting) {
513
+ this.ctx.queueCompactionMessage(text, "followUp");
514
+ return;
515
+ }
516
+
517
+ if (this.ctx.session.isStreaming) {
518
+ this.ctx.editor.addToHistory(text);
519
+ this.ctx.editor.setText("");
520
+ await this.ctx.session.prompt(text, { streamingBehavior: "followUp" });
521
+ this.ctx.updatePendingMessagesDisplay();
522
+ this.ctx.ui.requestRender();
523
+ return;
524
+ }
525
+
526
+ // Not streaming — just submit normally
527
+ this.ctx.editor.addToHistory(text);
528
+ this.ctx.editor.setText("");
529
+ await this.ctx.session.prompt(text);
530
+ }
531
+
522
532
  restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
523
533
  const { steering, followUp } = this.ctx.session.clearQueue();
524
534
  const allQueued = [...steering, ...followUp];
@@ -681,10 +691,14 @@ export class InputController {
681
691
  }
682
692
 
683
693
  toggleToolOutputExpansion(): void {
684
- this.ctx.toolOutputExpanded = !this.ctx.toolOutputExpanded;
694
+ this.setToolsExpanded(!this.ctx.toolOutputExpanded);
695
+ }
696
+
697
+ setToolsExpanded(expanded: boolean): void {
698
+ this.ctx.toolOutputExpanded = expanded;
685
699
  for (const child of this.ctx.chatContainer.children) {
686
700
  if (isExpandable(child)) {
687
- child.setExpanded(this.ctx.toolOutputExpanded);
701
+ child.setExpanded(expanded);
688
702
  }
689
703
  }
690
704
  this.ctx.ui.requestRender();
@@ -84,7 +84,7 @@ export class SelectorController {
84
84
  },
85
85
  getStatusLinePreview: () => {
86
86
  // Return the rendered status line for inline preview
87
- const width = this.ctx.ui.getWidth();
87
+ const width = this.ctx.ui.terminal.columns;
88
88
  return this.ctx.statusLine.getTopBorder(width).content;
89
89
  },
90
90
  onPluginsChanged: () => {
@@ -185,6 +185,10 @@ export class SelectorController {
185
185
  this.ctx.updateEditorBorderColor();
186
186
  break;
187
187
 
188
+ case "clearOnShrink":
189
+ this.ctx.ui.setClearOnShrink(value as boolean);
190
+ break;
191
+
188
192
  // Settings with UI side effects
189
193
  case "showImages":
190
194
  for (const child of this.ctx.chatContainer.children) {
@@ -354,15 +358,6 @@ export class SelectorController {
354
358
  const tree = this.ctx.sessionManager.getTree();
355
359
  const realLeafId = this.ctx.sessionManager.getLeafId();
356
360
 
357
- // Find the visible leaf for display (skip metadata entries like labels)
358
- let visibleLeafId = realLeafId;
359
- while (visibleLeafId) {
360
- const entry = this.ctx.sessionManager.getEntry(visibleLeafId);
361
- if (!entry) break;
362
- if (entry.type !== "label" && entry.type !== "custom") break;
363
- visibleLeafId = entry.parentId ?? null;
364
- }
365
-
366
361
  if (tree.length === 0) {
367
362
  this.ctx.showStatus("No entries in session");
368
363
  return;
@@ -371,11 +366,11 @@ export class SelectorController {
371
366
  this.showSelector(done => {
372
367
  const selector = new TreeSelectorComponent(
373
368
  tree,
374
- visibleLeafId,
369
+ realLeafId,
375
370
  this.ctx.ui.terminal.rows,
376
371
  async entryId => {
377
- // Selecting the visible leaf is a no-op (already there)
378
- if (entryId === visibleLeafId) {
372
+ // Selecting the current leaf is a no-op (already there)
373
+ if (entryId === realLeafId) {
379
374
  done();
380
375
  this.ctx.showStatus("Already at this point");
381
376
  return;
@@ -458,7 +453,7 @@ export class SelectorController {
458
453
  this.ctx.chatContainer.clear();
459
454
  this.ctx.renderInitialMessages();
460
455
  await this.ctx.reloadTodos();
461
- if (result.editorText) {
456
+ if (result.editorText && !this.ctx.editor.getText().trim()) {
462
457
  this.ctx.editor.setText(result.editorText);
463
458
  }
464
459
  this.ctx.showStatus("Navigated to selected point");
@@ -552,7 +547,7 @@ export class SelectorController {
552
547
  done();
553
548
 
554
549
  if (mode === "login") {
555
- this.ctx.showStatus(`Logging in to ${providerId}...`);
550
+ this.ctx.showStatus(`Logging in to ${providerId}…`);
556
551
 
557
552
  try {
558
553
  await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
@@ -17,12 +17,13 @@ import {
17
17
  } from "@oh-my-pi/pi-tui";
18
18
  import { $env, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
19
19
  import chalk from "chalk";
20
+ import { APP_NAME } from "../config";
20
21
  import { KeybindingsManager } from "../config/keybindings";
21
22
  import { renderPromptTemplate } from "../config/prompt-templates";
22
23
  import { type Settings, settings } from "../config/settings";
23
24
  import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../extensibility/extensions";
24
25
  import type { CompactOptions } from "../extensibility/extensions/types";
25
- import { loadSlashCommands } from "../extensibility/slash-commands";
26
+ import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slash-commands";
26
27
  import { resolvePlanUrlToPath } from "../internal-urls";
27
28
  import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" };
28
29
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
@@ -173,6 +174,7 @@ export class InteractiveMode implements InteractiveModeContext {
173
174
  this.mcpManager = mcpManager;
174
175
 
175
176
  this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
177
+ this.ui.setClearOnShrink(settings.get("clearOnShrink"));
176
178
  setMermaidRenderCallback(() => this.ui.requestRender());
177
179
  this.chatContainer = new Container();
178
180
  this.pendingMessagesContainer = new Container();
@@ -199,39 +201,10 @@ export class InteractiveMode implements InteractiveModeContext {
199
201
 
200
202
  this.hideThinkingBlock = settings.get("hideThinkingBlock");
201
203
 
202
- // Define slash commands for autocomplete
203
- const slashCommands: SlashCommand[] = [
204
- { name: "settings", description: "Open settings menu" },
205
- { name: "plan", description: "Toggle plan mode (agent plans before executing)" },
206
- { name: "model", description: "Select model (opens selector UI)" },
207
- { name: "export", description: "Export session to HTML file" },
208
- { name: "dump", description: "Copy session transcript to clipboard" },
209
- { name: "share", description: "Share session as a secret GitHub gist" },
210
- { name: "browser", description: "Toggle browser headless vs visible mode" },
211
- { name: "copy", description: "Copy last agent message to clipboard" },
212
- { name: "session", description: "Show session info and stats" },
213
- { name: "usage", description: "Show provider usage and limits" },
214
- { name: "extensions", description: "Open Extension Control Center dashboard" },
215
- { name: "status", description: "Alias for /extensions" },
216
- { name: "changelog", description: "Show changelog entries" },
217
- { name: "hotkeys", description: "Show all keyboard shortcuts" },
218
- { name: "branch", description: "Create a new branch from a previous message" },
219
- { name: "tree", description: "Navigate session tree (switch branches)" },
220
- { name: "login", description: "Login with OAuth provider" },
221
- { name: "logout", description: "Logout from OAuth provider" },
222
- { name: "new", description: "Start a new session" },
223
- { name: "fork", description: "Duplicate current session into a new session" },
224
- { name: "compact", description: "Manually compact the session context" },
225
- { name: "handoff", description: "Hand off the session context to a new session" },
226
- { name: "background", description: "Detach UI and continue running in background" },
227
- { name: "bg", description: "Alias for /background" },
228
- { name: "resume", description: "Resume a different session" },
229
- { name: "debug", description: "Write debug log (TUI state and messages)" },
230
- { name: "exit", description: "Exit the application" },
231
- ];
232
-
233
- // Convert hook commands to SlashCommand format
234
- const hookCommands: SlashCommand[] = (this.session.extensionRunner?.getRegisteredCommands() ?? []).map(cmd => ({
204
+ const builtinCommandNames = new Set(BUILTIN_SLASH_COMMANDS.map(c => c.name));
205
+ const hookCommands: SlashCommand[] = (
206
+ this.session.extensionRunner?.getRegisteredCommands(builtinCommandNames) ?? []
207
+ ).map(cmd => ({
235
208
  name: cmd.name,
236
209
  description: cmd.description ?? "(hook command)",
237
210
  getArgumentCompletions: cmd.getArgumentCompletions,
@@ -254,7 +227,7 @@ export class InteractiveMode implements InteractiveModeContext {
254
227
  }
255
228
 
256
229
  // Store pending commands for init() where file commands are loaded async
257
- this.pendingSlashCommands = [...slashCommands, ...hookCommands, ...customCommands, ...skillCommandList];
230
+ this.pendingSlashCommands = [...BUILTIN_SLASH_COMMANDS, ...hookCommands, ...customCommands, ...skillCommandList];
258
231
 
259
232
  this.uiHelpers = new UiHelpers(this);
260
233
  this.extensionUiController = new ExtensionUiController(this);
@@ -373,6 +346,9 @@ export class InteractiveMode implements InteractiveModeContext {
373
346
  // Initialize hooks with TUI-based UI context
374
347
  await this.initHooksAndCustomTools();
375
348
 
349
+ // Restore mode from session (e.g. plan mode on resume)
350
+ await this.restoreModeFromSession();
351
+
376
352
  // Subscribe to agent events
377
353
  this.subscribeToAgent();
378
354
 
@@ -416,7 +392,7 @@ export class InteractiveMode implements InteractiveModeContext {
416
392
  }
417
393
 
418
394
  updateEditorTopBorder(): void {
419
- const width = this.ui.getWidth();
395
+ const width = this.ui.terminal.columns;
420
396
  const topBorder = this.statusLine.getTopBorder(width);
421
397
  this.editor.setTopBorder(topBorder);
422
398
  }
@@ -561,6 +537,19 @@ export class InteractiveMode implements InteractiveModeContext {
561
537
  }
562
538
  }
563
539
 
540
+ /** Restore mode state from session entries on resume (e.g. plan mode). */
541
+ private async restoreModeFromSession(): Promise<void> {
542
+ const sessionContext = this.sessionManager.buildSessionContext();
543
+ if (sessionContext.mode === "plan") {
544
+ const planFilePath = sessionContext.modeData?.planFilePath as string | undefined;
545
+ await this.enterPlanMode({ planFilePath });
546
+ } else if (sessionContext.mode === "plan_paused") {
547
+ this.planModePaused = true;
548
+ this.planModeHasEntered = true;
549
+ this.updatePlanModeStatus();
550
+ }
551
+ }
552
+
564
553
  private async enterPlanMode(options?: {
565
554
  planFilePath?: string;
566
555
  workflow?: "parallel" | "iterative";
@@ -594,6 +583,7 @@ export class InteractiveMode implements InteractiveModeContext {
594
583
  this.planModeHasEntered = true;
595
584
  await this.applyPlanModeModel();
596
585
  this.updatePlanModeStatus();
586
+ this.sessionManager.appendModeChange("plan", { planFilePath });
597
587
  this.showStatus(`Plan mode enabled. Plan file: ${planFilePath}`);
598
588
  }
599
589
 
@@ -621,8 +611,10 @@ export class InteractiveMode implements InteractiveModeContext {
621
611
  this.planModePreviousTools = undefined;
622
612
  this.planModePreviousModel = undefined;
623
613
  this.updatePlanModeStatus();
614
+ const paused = options?.paused ?? false;
615
+ this.sessionManager.appendModeChange(paused ? "plan_paused" : "none");
624
616
  if (!options?.silent) {
625
- this.showStatus(this.planModePaused ? "Plan mode paused." : "Plan mode disabled.");
617
+ this.showStatus(paused ? "Plan mode paused." : "Plan mode disabled.");
626
618
  }
627
619
  }
628
620
 
@@ -735,10 +727,26 @@ export class InteractiveMode implements InteractiveModeContext {
735
727
  await this.session.emitCustomToolSessionEvent("shutdown");
736
728
 
737
729
  if (this.isInitialized) {
738
- await this.ui.waitForRender();
730
+ this.ui.requestRender(true);
739
731
  }
740
732
 
733
+ // Wait for any pending renders to complete
734
+ // requestRender() uses process.nextTick(), so we wait one tick
735
+ await new Promise(resolve => process.nextTick(resolve));
736
+
737
+ // Drain any in-flight Kitty key release events before stopping.
738
+ // This prevents escape sequences from leaking to the parent shell over slow SSH.
739
+ await this.ui.terminal.drainInput(1000);
740
+
741
741
  this.stop();
742
+
743
+ // Print resumption hint if this is a persisted session
744
+ const sessionId = this.sessionManager.getSessionId();
745
+ const sessionFile = this.sessionManager.getSessionFile();
746
+ if (sessionId && sessionFile) {
747
+ process.stderr.write(`\n${chalk.dim(`Resume this session with ${APP_NAME} --resume ${sessionId}`)}\n`);
748
+ }
749
+
742
750
  await postmortem.quit(0);
743
751
  }
744
752
 
@@ -1011,6 +1019,10 @@ export class InteractiveMode implements InteractiveModeContext {
1011
1019
  this.inputController.toggleToolOutputExpansion();
1012
1020
  }
1013
1021
 
1022
+ setToolsExpanded(expanded: boolean): void {
1023
+ this.inputController.setToolsExpanded(expanded);
1024
+ }
1025
+
1014
1026
  toggleThinkingBlockVisibility(): void {
1015
1027
  this.inputController.toggleThinkingBlockVisibility();
1016
1028
  }
@@ -63,6 +63,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
63
63
  getActiveTools: () => session.getActiveToolNames(),
64
64
  getAllTools: () => session.getAllToolNames(),
65
65
  setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
66
+ getCommands: () => [],
66
67
  setModel: async model => {
67
68
  const key = await session.modelRegistry.getApiKey(model);
68
69
  if (!key) return false;
@@ -80,6 +81,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
80
81
  hasPendingMessages: () => session.queuedMessageCount > 0,
81
82
  shutdown: () => {},
82
83
  getContextUsage: () => session.getContextUsage(),
84
+ getSystemPrompt: () => session.systemPrompt,
83
85
  compact: async instructionsOrOptions => {
84
86
  const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
85
87
  const options =
@@ -108,6 +110,10 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
108
110
  const result = await session.navigateTree(targetId, { summarize: options?.summarize });
109
111
  return { cancelled: result.cancelled };
110
112
  },
113
+ switchSession: async sessionPath => {
114
+ const success = await session.switchSession(sessionPath);
115
+ return { cancelled: !success };
116
+ },
111
117
  compact: async instructionsOrOptions => {
112
118
  const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
113
119
  const options =
@@ -189,15 +189,15 @@ export class RpcClient {
189
189
  /**
190
190
  * Queue a steering message to interrupt the agent mid-run.
191
191
  */
192
- async steer(message: string): Promise<void> {
193
- await this.send({ type: "steer", message });
192
+ async steer(message: string, images?: ImageContent[]): Promise<void> {
193
+ await this.send({ type: "steer", message, images });
194
194
  }
195
195
 
196
196
  /**
197
197
  * Queue a follow-up message to be processed after the agent finishes.
198
198
  */
199
- async followUp(message: string): Promise<void> {
200
- await this.send({ type: "follow_up", message });
199
+ async followUp(message: string, images?: ImageContent[]): Promise<void> {
200
+ await this.send({ type: "follow_up", message, images });
201
201
  }
202
202
 
203
203
  /**