@oh-my-pi/pi-coding-agent 15.10.0 → 15.10.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 (238) hide show
  1. package/CHANGELOG.md +142 -1
  2. package/dist/types/cli/dry-balance-cli.d.ts +15 -1
  3. package/dist/types/cli/startup-cwd.d.ts +2 -0
  4. package/dist/types/commands/launch.d.ts +3 -0
  5. package/dist/types/commit/analysis/conventional.d.ts +2 -2
  6. package/dist/types/commit/analysis/summary.d.ts +2 -2
  7. package/dist/types/commit/changelog/generate.d.ts +2 -2
  8. package/dist/types/commit/changelog/index.d.ts +2 -2
  9. package/dist/types/commit/map-reduce/index.d.ts +3 -3
  10. package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
  11. package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
  12. package/dist/types/commit/model-selection.d.ts +10 -4
  13. package/dist/types/config/api-key-resolver.d.ts +34 -0
  14. package/dist/types/config/keybindings.d.ts +2 -2
  15. package/dist/types/config/model-provider-priority.d.ts +1 -0
  16. package/dist/types/config/model-registry.d.ts +17 -1
  17. package/dist/types/config/model-resolver.d.ts +4 -1
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/config/settings.d.ts +7 -2
  20. package/dist/types/dap/config.d.ts +14 -1
  21. package/dist/types/dap/types.d.ts +10 -0
  22. package/dist/types/debug/report-bundle.d.ts +3 -0
  23. package/dist/types/edit/file-snapshot-store.d.ts +18 -10
  24. package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
  25. package/dist/types/extensibility/extensions/types.d.ts +4 -1
  26. package/dist/types/lsp/client.d.ts +10 -0
  27. package/dist/types/lsp/utils.d.ts +3 -2
  28. package/dist/types/main.d.ts +3 -9
  29. package/dist/types/mcp/tool-bridge.d.ts +2 -0
  30. package/dist/types/modes/components/chat-block.d.ts +64 -0
  31. package/dist/types/modes/components/custom-editor.d.ts +4 -1
  32. package/dist/types/modes/components/overlay-box.d.ts +17 -0
  33. package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
  34. package/dist/types/modes/components/plan-toc.d.ts +41 -0
  35. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  36. package/dist/types/modes/components/status-line.d.ts +2 -0
  37. package/dist/types/modes/components/transcript-container.d.ts +11 -0
  38. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  39. package/dist/types/modes/controllers/event-controller.d.ts +17 -1
  40. package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
  41. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  42. package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
  43. package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
  44. package/dist/types/modes/interactive-mode.d.ts +16 -5
  45. package/dist/types/modes/magic-keywords.d.ts +1 -1
  46. package/dist/types/modes/markdown-prose.d.ts +1 -1
  47. package/dist/types/modes/theme/theme.d.ts +1 -1
  48. package/dist/types/modes/types.d.ts +21 -5
  49. package/dist/types/modes/utils/copy-targets.d.ts +21 -1
  50. package/dist/types/modes/workflow.d.ts +3 -3
  51. package/dist/types/plan-mode/approved-plan.d.ts +27 -8
  52. package/dist/types/plan-mode/plan-protection.d.ts +4 -4
  53. package/dist/types/sdk.d.ts +2 -0
  54. package/dist/types/session/agent-session.d.ts +21 -0
  55. package/dist/types/session/auth-storage.d.ts +1 -1
  56. package/dist/types/session/messages.d.ts +12 -0
  57. package/dist/types/session/session-manager.d.ts +8 -3
  58. package/dist/types/slash-commands/types.d.ts +4 -6
  59. package/dist/types/task/executor.d.ts +17 -0
  60. package/dist/types/task/index.d.ts +1 -0
  61. package/dist/types/task/render.d.ts +3 -2
  62. package/dist/types/tools/archive-reader.d.ts +5 -0
  63. package/dist/types/tools/ast-edit.d.ts +3 -0
  64. package/dist/types/tools/ast-grep.d.ts +3 -0
  65. package/dist/types/tools/bash.d.ts +1 -0
  66. package/dist/types/tools/eval.d.ts +8 -0
  67. package/dist/types/tools/find.d.ts +8 -4
  68. package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
  69. package/dist/types/tools/github-cache.d.ts +12 -0
  70. package/dist/types/tools/grouped-file-output.d.ts +95 -12
  71. package/dist/types/tools/memory-render.d.ts +4 -1
  72. package/dist/types/tools/path-utils.d.ts +8 -0
  73. package/dist/types/tools/plan-mode-guard.d.ts +8 -9
  74. package/dist/types/tools/render-utils.d.ts +5 -9
  75. package/dist/types/tools/search.d.ts +6 -2
  76. package/dist/types/tools/sqlite-reader.d.ts +1 -0
  77. package/dist/types/tools/todo.d.ts +3 -2
  78. package/dist/types/tools/write.d.ts +3 -0
  79. package/dist/types/tools/yield.d.ts +8 -0
  80. package/dist/types/tui/output-block.d.ts +16 -4
  81. package/dist/types/tui/status-line.d.ts +3 -0
  82. package/dist/types/utils/enhanced-paste.d.ts +20 -0
  83. package/dist/types/web/search/providers/kimi.d.ts +1 -1
  84. package/package.json +9 -9
  85. package/src/auto-thinking/classifier.ts +5 -1
  86. package/src/cli/args.ts +3 -1
  87. package/src/cli/dry-balance-cli.ts +54 -21
  88. package/src/cli/gallery-cli.ts +4 -1
  89. package/src/cli/gallery-fixtures/misc.ts +29 -0
  90. package/src/cli/startup-cwd.ts +68 -0
  91. package/src/commands/launch.ts +3 -0
  92. package/src/commit/analysis/conventional.ts +2 -2
  93. package/src/commit/analysis/summary.ts +2 -2
  94. package/src/commit/changelog/generate.ts +2 -2
  95. package/src/commit/changelog/index.ts +2 -2
  96. package/src/commit/map-reduce/index.ts +3 -3
  97. package/src/commit/map-reduce/map-phase.ts +2 -2
  98. package/src/commit/map-reduce/reduce-phase.ts +2 -2
  99. package/src/commit/model-selection.ts +36 -11
  100. package/src/commit/pipeline.ts +4 -4
  101. package/src/config/api-key-resolver.ts +58 -0
  102. package/src/config/model-provider-priority.ts +55 -0
  103. package/src/config/model-registry.ts +29 -24
  104. package/src/config/model-resolver.ts +39 -7
  105. package/src/config/settings-schema.ts +10 -0
  106. package/src/config/settings.ts +106 -43
  107. package/src/dap/config.ts +41 -2
  108. package/src/dap/defaults.json +1 -0
  109. package/src/dap/session.ts +1 -0
  110. package/src/dap/types.ts +10 -0
  111. package/src/debug/index.ts +47 -53
  112. package/src/debug/raw-sse-buffer.ts +7 -4
  113. package/src/debug/report-bundle.ts +9 -0
  114. package/src/edit/file-snapshot-store.ts +33 -1
  115. package/src/edit/hashline/filesystem.ts +2 -1
  116. package/src/edit/renderer.ts +82 -78
  117. package/src/eval/__tests__/llm-bridge.test.ts +110 -31
  118. package/src/eval/js/context-manager.ts +32 -15
  119. package/src/eval/llm-bridge.ts +22 -6
  120. package/src/eval/py/__tests__/prelude.test.ts +19 -0
  121. package/src/eval/py/executor.ts +23 -11
  122. package/src/eval/py/prelude.py +1 -1
  123. package/src/extensibility/extensions/types.ts +10 -1
  124. package/src/goals/tools/goal-tool.ts +36 -26
  125. package/src/internal-urls/docs-index.generated.ts +8 -8
  126. package/src/lsp/client.ts +23 -11
  127. package/src/lsp/config.ts +11 -1
  128. package/src/lsp/index.ts +61 -9
  129. package/src/lsp/utils.ts +3 -2
  130. package/src/main.ts +100 -72
  131. package/src/mcp/tool-bridge.ts +2 -0
  132. package/src/memories/index.ts +14 -7
  133. package/src/mnemopi/backend.ts +5 -1
  134. package/src/modes/acp/acp-agent.ts +33 -26
  135. package/src/modes/components/assistant-message.ts +2 -9
  136. package/src/modes/components/chat-block.ts +111 -0
  137. package/src/modes/components/copy-selector.ts +1 -44
  138. package/src/modes/components/custom-editor.ts +164 -109
  139. package/src/modes/components/custom-message.ts +1 -3
  140. package/src/modes/components/execution-shared.ts +1 -2
  141. package/src/modes/components/hook-message.ts +1 -3
  142. package/src/modes/components/model-selector.ts +59 -13
  143. package/src/modes/components/oauth-selector.ts +33 -7
  144. package/src/modes/components/overlay-box.ts +108 -0
  145. package/src/modes/components/plan-review-overlay.ts +799 -0
  146. package/src/modes/components/plan-toc.ts +138 -0
  147. package/src/modes/components/read-tool-group.ts +20 -4
  148. package/src/modes/components/skill-message.ts +0 -1
  149. package/src/modes/components/status-line.ts +19 -4
  150. package/src/modes/components/tips.txt +2 -1
  151. package/src/modes/components/todo-reminder.ts +0 -2
  152. package/src/modes/components/tool-execution.ts +68 -88
  153. package/src/modes/components/transcript-container.ts +84 -24
  154. package/src/modes/components/user-message.ts +2 -3
  155. package/src/modes/controllers/command-controller-shared.ts +7 -6
  156. package/src/modes/controllers/command-controller.ts +57 -55
  157. package/src/modes/controllers/event-controller.ts +67 -40
  158. package/src/modes/controllers/extension-ui-controller.ts +10 -73
  159. package/src/modes/controllers/input-controller.ts +170 -126
  160. package/src/modes/controllers/mcp-command-controller.ts +69 -60
  161. package/src/modes/controllers/selector-controller.ts +23 -25
  162. package/src/modes/controllers/streaming-reveal.ts +212 -0
  163. package/src/modes/controllers/tan-command-controller.ts +173 -0
  164. package/src/modes/interactive-mode.ts +274 -112
  165. package/src/modes/magic-keywords.ts +1 -1
  166. package/src/modes/markdown-prose.ts +1 -1
  167. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  168. package/src/modes/theme/shimmer.ts +20 -9
  169. package/src/modes/theme/theme-schema.json +1 -1
  170. package/src/modes/theme/theme.ts +8 -4
  171. package/src/modes/types.ts +21 -7
  172. package/src/modes/utils/copy-targets.ts +133 -27
  173. package/src/modes/utils/ui-helpers.ts +44 -46
  174. package/src/modes/workflow.ts +10 -10
  175. package/src/plan-mode/approved-plan.ts +66 -43
  176. package/src/plan-mode/plan-protection.ts +4 -4
  177. package/src/prompts/system/background-tan-dispatch.md +8 -0
  178. package/src/prompts/system/plan-mode-active.md +67 -58
  179. package/src/prompts/system/plan-mode-approved.md +1 -1
  180. package/src/prompts/system/workflow-notice.md +1 -1
  181. package/src/prompts/tools/bash.md +9 -0
  182. package/src/prompts/tools/browser.md +1 -1
  183. package/src/prompts/tools/eval.md +2 -1
  184. package/src/prompts/tools/read.md +2 -2
  185. package/src/sdk.ts +37 -46
  186. package/src/session/agent-session.ts +119 -18
  187. package/src/session/auth-storage.ts +2 -0
  188. package/src/session/messages.ts +26 -0
  189. package/src/session/session-manager.ts +109 -28
  190. package/src/slash-commands/builtin-registry.ts +36 -9
  191. package/src/slash-commands/types.ts +4 -6
  192. package/src/task/executor.ts +76 -38
  193. package/src/task/index.ts +4 -0
  194. package/src/task/render.ts +211 -147
  195. package/src/tools/archive-reader.ts +64 -0
  196. package/src/tools/ask.ts +119 -164
  197. package/src/tools/ast-edit.ts +98 -71
  198. package/src/tools/ast-grep.ts +37 -43
  199. package/src/tools/bash.ts +57 -6
  200. package/src/tools/browser/tab-supervisor.ts +13 -1
  201. package/src/tools/browser/tab-worker.ts +33 -4
  202. package/src/tools/debug.ts +20 -8
  203. package/src/tools/eval.ts +13 -2
  204. package/src/tools/fetch.ts +297 -7
  205. package/src/tools/find.ts +51 -30
  206. package/src/tools/gh-cache-invalidation.ts +200 -0
  207. package/src/tools/gh-renderer.ts +81 -42
  208. package/src/tools/github-cache.ts +25 -0
  209. package/src/tools/grouped-file-output.ts +272 -48
  210. package/src/tools/image-gen.ts +150 -103
  211. package/src/tools/inspect-image-renderer.ts +63 -41
  212. package/src/tools/inspect-image.ts +10 -3
  213. package/src/tools/job.ts +3 -4
  214. package/src/tools/memory-render.ts +4 -1
  215. package/src/tools/path-utils.ts +28 -2
  216. package/src/tools/plan-mode-guard.ts +66 -39
  217. package/src/tools/read.ts +48 -28
  218. package/src/tools/render-utils.ts +21 -37
  219. package/src/tools/resolve.ts +14 -0
  220. package/src/tools/search-tool-bm25.ts +36 -23
  221. package/src/tools/search.ts +118 -81
  222. package/src/tools/sqlite-reader.ts +9 -12
  223. package/src/tools/todo.ts +118 -52
  224. package/src/tools/write.ts +83 -64
  225. package/src/tools/yield.ts +10 -1
  226. package/src/tui/output-block.ts +60 -13
  227. package/src/tui/status-line.ts +5 -1
  228. package/src/utils/commit-message-generator.ts +11 -3
  229. package/src/utils/enhanced-paste.ts +230 -0
  230. package/src/utils/title-generator.ts +2 -1
  231. package/src/web/search/providers/anthropic.ts +25 -19
  232. package/src/web/search/providers/codex.ts +37 -8
  233. package/src/web/search/providers/exa.ts +11 -3
  234. package/src/web/search/providers/kimi.ts +28 -17
  235. package/src/web/search/providers/parallel.ts +35 -24
  236. package/src/web/search/providers/synthetic.ts +8 -6
  237. package/src/web/search/providers/tavily.ts +9 -8
  238. package/src/web/search/providers/zai.ts +8 -6
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
2
3
  import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
3
4
  import { $env, logger, sanitizeText } from "@oh-my-pi/pi-utils";
4
5
  import { getRoleInfo } from "../../config/model-registry";
@@ -9,16 +10,16 @@ import { expandEmoticons } from "../../modes/emoji-autocomplete";
9
10
  import { materializeImageReferenceLinks } from "../../modes/image-references";
10
11
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
11
12
  import type { InteractiveModeContext } from "../../modes/types";
12
- import type { AgentSessionEvent } from "../../session/agent-session";
13
- import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
13
+ import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails, USER_INTERRUPT_LABEL } from "../../session/messages";
14
14
  import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
15
15
  import { isTinyTitleLocalModelKey } from "../../tiny/models";
16
16
  import { isLowSignalTitleInput } from "../../tiny/text";
17
17
  import { tinyTitleClient } from "../../tiny/title-client";
18
18
  import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
19
19
  import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard";
20
+ import { EnhancedPasteController } from "../../utils/enhanced-paste";
20
21
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
21
- import { ensureSupportedImageInput } from "../../utils/image-loading";
22
+ import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
22
23
  import { resizeImage } from "../../utils/image-resize";
23
24
  import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
24
25
 
@@ -40,8 +41,10 @@ const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
40
41
  export class InputController {
41
42
  constructor(private ctx: InteractiveModeContext) {}
42
43
 
44
+ #enhancedPaste?: EnhancedPasteController;
45
+
43
46
  #showTinyTitleDownloadProgress(modelKey: string): void {
44
- if (!isTinyTitleLocalModelKey(modelKey) || this.ctx.isBackgrounded) return;
47
+ if (!isTinyTitleLocalModelKey(modelKey)) return;
45
48
  const component = new TinyTitleDownloadProgressComponent(modelKey);
46
49
  let added = false;
47
50
  let disposed = false;
@@ -91,7 +94,8 @@ export class InputController {
91
94
  if (this.ctx.loopModeEnabled) {
92
95
  this.ctx.pauseLoop();
93
96
  if (this.ctx.session.isStreaming) {
94
- void this.ctx.session.abort();
97
+ this.ctx.notifyInterrupting();
98
+ void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
95
99
  } else {
96
100
  this.ctx.cancelPendingSubmission();
97
101
  }
@@ -121,7 +125,8 @@ export class InputController {
121
125
  this.ctx.isPythonMode = false;
122
126
  this.ctx.updateEditorBorderColor();
123
127
  } else if (this.ctx.session.isStreaming) {
124
- void this.ctx.session.abort();
128
+ this.ctx.notifyInterrupting();
129
+ void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
125
130
  } else if (!this.ctx.editor.getText().trim()) {
126
131
  // Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting
127
132
  const action = settings.get("doubleEscapeAction");
@@ -176,6 +181,7 @@ export class InputController {
176
181
  this.ctx.keybindings.getKeys("app.clipboard.pasteImage"),
177
182
  );
178
183
  this.ctx.editor.onPasteImage = () => this.handleImagePaste();
184
+ this.ctx.editor.onPasteImagePath = path => void this.handleImagePathPaste(path);
179
185
  this.ctx.editor.setActionKeys(
180
186
  "app.clipboard.pasteTextRaw",
181
187
  this.ctx.keybindings.getKeys("app.clipboard.pasteTextRaw"),
@@ -223,6 +229,8 @@ export class InputController {
223
229
  this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionObserver());
224
230
  }
225
231
 
232
+ this.#setupEnhancedPaste();
233
+
226
234
  this.ctx.editor.onChange = (text: string) => {
227
235
  const wasBashMode = this.ctx.isBashMode;
228
236
  const wasPythonMode = this.ctx.isPythonMode;
@@ -235,16 +243,45 @@ export class InputController {
235
243
  };
236
244
  }
237
245
 
246
+ #setupEnhancedPaste(): void {
247
+ if (this.#enhancedPaste) return;
248
+
249
+ this.#enhancedPaste = new EnhancedPasteController({
250
+ write: data => this.ctx.ui.terminal.write(data),
251
+ pasteText: text => {
252
+ this.ctx.editor.pasteText(text);
253
+ this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
254
+ },
255
+ pasteImage: async image => {
256
+ await this.#normalizeAndInsertPastedImage(image, `Unsupported pasted image format: ${image.mimeType}`);
257
+ },
258
+ showStatus: message => this.ctx.showStatus(message),
259
+ });
260
+ this.ctx.ui.addInputListener(data => (this.#enhancedPaste?.handleInput(data) ? { consume: true } : undefined));
261
+ this.ctx.ui.addStartListener(() => this.#enhancedPaste?.enable());
262
+ }
263
+
238
264
  setupEditorSubmitHandler(): void {
239
265
  this.ctx.editor.onSubmit = async (text: string) => {
240
266
  text = text.trim();
241
267
  if ((!isSettingsInitialized() || settings.get("emojiAutocomplete")) && text) text = expandEmoticons(text);
242
268
 
243
- // Empty submit while streaming with queued messages: flush queues immediately
244
- if (!text && this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount > 0) {
245
- // Abort current stream and let queued messages be processed
246
- await this.ctx.session.abort();
247
- return;
269
+ // Empty submit while streaming with queued steering: interrupt now and
270
+ // immediately resume so the visible `Steer:` entry is sent without
271
+ // waiting for the current tool/model boundary.
272
+ if (!text && this.ctx.session.isStreaming) {
273
+ const queuedMessages = this.ctx.session.getQueuedMessages();
274
+ if (queuedMessages.steering.length > 0) {
275
+ await this.ctx.session.interruptAndFlushQueuedMessages({ reason: USER_INTERRUPT_LABEL });
276
+ this.ctx.updatePendingMessagesDisplay();
277
+ this.ctx.ui.requestRender();
278
+ return;
279
+ }
280
+ if (this.ctx.session.queuedMessageCount > 0) {
281
+ // Preserve the existing empty-submit flush for non-steer queues.
282
+ await this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
283
+ return;
284
+ }
248
285
  }
249
286
 
250
287
  if (!text) return;
@@ -291,7 +328,6 @@ export class InputController {
291
328
  // Handle built-in slash commands
292
329
  const slashResult = await executeBuiltinSlashCommand(text, {
293
330
  ctx: this.ctx,
294
- handleBackgroundCommand: () => this.handleBackgroundCommand(),
295
331
  });
296
332
  if (slashResult === true) {
297
333
  return;
@@ -465,17 +501,44 @@ export class InputController {
465
501
  }
466
502
 
467
503
  handleCtrlZ(): void {
468
- // Set up handler to restore TUI when resumed
469
- process.once("SIGCONT", () => {
504
+ // SIGTSTP is POSIX job-control: Windows has no equivalent and
505
+ // `process.kill(_, "SIGTSTP")` throws `TypeError: Unknown signal:
506
+ // SIGTSTP` there, taking the whole agent down via an uncaught
507
+ // exception (issue #2036). No-op on platforms that cannot suspend.
508
+ if (process.platform === "win32") {
509
+ this.ctx.showStatus("Suspend (Ctrl+Z) is not supported on this platform");
510
+ return;
511
+ }
512
+
513
+ // Capture the listener so we can detach it if the signal never
514
+ // fires; otherwise a failed suspend would leave a stale SIGCONT
515
+ // handler that fires on the next unrelated continue and tries to
516
+ // re-`start()` an already-running TUI.
517
+ const onResume = (): void => {
470
518
  this.ctx.ui.start();
471
519
  this.ctx.ui.requestRender(true);
472
- });
520
+ };
521
+ process.once("SIGCONT", onResume);
473
522
 
474
- // Stop the TUI (restore terminal to normal mode)
523
+ // Stop the TUI (restore terminal to normal mode) before sending the
524
+ // signal so the parent shell sees a sane terminal state.
475
525
  this.ctx.ui.stop();
476
526
 
477
- // Send SIGTSTP to process group (pid=0 means all processes in group)
478
- process.kill(0, "SIGTSTP");
527
+ try {
528
+ // pid=0 → entire foreground process group; the shell receives
529
+ // SIGTSTP and parks the job.
530
+ process.kill(0, "SIGTSTP");
531
+ } catch (err) {
532
+ // Either the runtime refused the signal or the kernel rejected
533
+ // it (some sandboxes block sending to pid=0). Tear the resume
534
+ // hook down and bring the TUI back so the user is not stranded
535
+ // on a frozen prompt.
536
+ process.removeListener("SIGCONT", onResume);
537
+ this.ctx.ui.start();
538
+ this.ctx.ui.requestRender(true);
539
+ const reason = err instanceof Error ? err.message : String(err);
540
+ this.ctx.showError(`Failed to suspend: ${reason}`);
541
+ }
479
542
  }
480
543
 
481
544
  handleDequeue(): void {
@@ -555,7 +618,7 @@ export class InputController {
555
618
 
556
619
  /** Send editor text as a follow-up message (queued behind current stream). */
557
620
  async handleFollowUp(): Promise<void> {
558
- const text = this.ctx.editor.getText().trim();
621
+ let text = this.ctx.editor.getText().trim();
559
622
  if (!text) return;
560
623
 
561
624
  // Compaction first: while compacting, free text gets queued via
@@ -569,6 +632,16 @@ export class InputController {
569
632
  return;
570
633
  }
571
634
 
635
+ const slashResult = await executeBuiltinSlashCommand(text, {
636
+ ctx: this.ctx,
637
+ });
638
+ if (slashResult === true) {
639
+ return;
640
+ }
641
+ if (typeof slashResult === "string") {
642
+ text = slashResult;
643
+ }
644
+
572
645
  // Skill commands invoke through the custom-message path regardless of
573
646
  // which keybinding submitted them. Enter routes them as `steer`;
574
647
  // Ctrl+Enter (this handler) routes them as `followUp`.
@@ -600,7 +673,7 @@ export class InputController {
600
673
  if (allQueued.length === 0) {
601
674
  this.ctx.updatePendingMessagesDisplay();
602
675
  if (options?.abort) {
603
- this.ctx.session.abort();
676
+ this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
604
677
  }
605
678
  return 0;
606
679
  }
@@ -610,128 +683,99 @@ export class InputController {
610
683
  this.ctx.editor.setText(combinedText);
611
684
  this.ctx.updatePendingMessagesDisplay();
612
685
  if (options?.abort) {
613
- this.ctx.session.abort();
686
+ this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
614
687
  }
615
688
  return allQueued.length;
616
689
  }
617
690
 
618
- handleBackgroundCommand(): void {
619
- if (this.ctx.isBackgrounded) {
620
- this.ctx.showStatus("Background mode already enabled");
621
- return;
622
- }
623
- if (!this.ctx.session.isStreaming && this.ctx.session.queuedMessageCount === 0) {
624
- this.ctx.showWarning("Agent is idle; nothing to background");
625
- return;
626
- }
627
- if (this.ctx.hasActiveBtw()) {
628
- this.ctx.handleBtwEscape();
629
- }
630
- if (this.ctx.hasActiveOmfg()) {
631
- this.ctx.handleOmfgEscape();
632
- }
633
-
634
- this.ctx.isBackgrounded = true;
635
- const backgroundUiContext = this.ctx.createBackgroundUiContext();
636
-
637
- // Background mode disables interactive UI so tools like ask fail fast.
638
- this.ctx.setToolUIContext(backgroundUiContext, false);
639
- this.ctx.initializeHookRunner(backgroundUiContext, false);
640
-
641
- if (this.ctx.loadingAnimation) {
642
- this.ctx.loadingAnimation.stop();
643
- this.ctx.loadingAnimation = undefined;
644
- }
645
- if (this.ctx.autoCompactionLoader) {
646
- this.ctx.autoCompactionLoader.stop();
647
- this.ctx.autoCompactionLoader = undefined;
648
- }
649
- if (this.ctx.retryLoader) {
650
- this.ctx.retryLoader.stop();
651
- this.ctx.retryLoader = undefined;
652
- }
653
- this.ctx.statusContainer.clear();
654
- this.ctx.statusLine.dispose();
655
-
656
- if (this.ctx.unsubscribe) {
657
- this.ctx.unsubscribe();
658
- }
659
- this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
660
- await this.ctx.handleBackgroundEvent(event);
691
+ async #insertPendingImage(imageData: ImageContent): Promise<void> {
692
+ const imageLink = (
693
+ await materializeImageReferenceLinks(
694
+ [
695
+ {
696
+ type: "image",
697
+ data: imageData.data,
698
+ mimeType: imageData.mimeType,
699
+ },
700
+ ],
701
+ this.ctx.sessionManager.putBlob.bind(this.ctx.sessionManager),
702
+ )
703
+ )?.[0];
704
+ this.ctx.pendingImages.push({
705
+ type: "image",
706
+ data: imageData.data,
707
+ mimeType: imageData.mimeType,
661
708
  });
709
+ this.ctx.pendingImageLinks.push(imageLink);
710
+ this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
711
+ const imageNum = this.ctx.pendingImages.length;
712
+ this.ctx.editor.insertText(`[Image #${imageNum}] `);
713
+ this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
714
+ }
662
715
 
663
- // Backgrounding keeps the current process to preserve in-flight agent state.
664
- if (this.ctx.isInitialized) {
665
- this.ctx.ui.stop();
666
- this.ctx.isInitialized = false;
716
+ async #normalizeAndInsertPastedImage(image: ImageContent, unsupportedMessage: string): Promise<boolean> {
717
+ let imageData = await ensureSupportedImageInput(image);
718
+ if (!imageData) {
719
+ this.ctx.showStatus(unsupportedMessage);
720
+ return false;
667
721
  }
668
-
669
- process.stdout.write("Background mode enabled. Run `bg` to continue in background.\n");
670
-
671
- if (process.platform === "win32" || !process.stdout.isTTY) {
672
- process.stdout.write("Backgrounding requires POSIX job control; continuing in foreground.\n");
673
- return;
722
+ if (settings.get("images.autoResize")) {
723
+ try {
724
+ const resized = await resizeImage({
725
+ type: "image",
726
+ data: imageData.data,
727
+ mimeType: imageData.mimeType,
728
+ });
729
+ imageData = { type: "image", data: resized.data, mimeType: resized.mimeType };
730
+ } catch {
731
+ // Keep the normalized image when resize fails.
732
+ }
674
733
  }
734
+ await this.#insertPendingImage(imageData);
735
+ return true;
736
+ }
675
737
 
676
- process.kill(0, "SIGTSTP");
738
+ async handleImagePathPaste(path: string): Promise<void> {
739
+ try {
740
+ const image = await loadImageInput({
741
+ path,
742
+ cwd: this.ctx.sessionManager.getCwd(),
743
+ autoResize: false,
744
+ });
745
+ if (!image) {
746
+ this.ctx.editor.pasteText(path);
747
+ this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
748
+ this.ctx.showStatus("Pasted path is not a supported image");
749
+ return;
750
+ }
751
+ await this.#normalizeAndInsertPastedImage(
752
+ { type: "image", data: image.data, mimeType: image.mimeType },
753
+ `Unsupported pasted image format: ${image.mimeType}`,
754
+ );
755
+ } catch (error) {
756
+ this.ctx.editor.pasteText(path);
757
+ this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
758
+ this.ctx.showStatus(
759
+ error instanceof ImageInputTooLargeError ? error.message : "Failed to read pasted image path",
760
+ );
761
+ }
677
762
  }
678
763
 
679
764
  async handleImagePaste(): Promise<boolean> {
680
765
  try {
681
766
  const image = await readImageFromClipboard();
682
- if (image) {
683
- const base64Data = image.data.toBase64();
684
- let imageData = await ensureSupportedImageInput({
767
+ if (!image) {
768
+ this.ctx.showStatus("No image in clipboard (use terminal paste for text)");
769
+ return false;
770
+ }
771
+ return await this.#normalizeAndInsertPastedImage(
772
+ {
685
773
  type: "image",
686
- data: base64Data,
774
+ data: image.data.toBase64(),
687
775
  mimeType: image.mimeType,
688
- });
689
- if (!imageData) {
690
- this.ctx.showStatus(`Unsupported clipboard image format: ${image.mimeType}`);
691
- return false;
692
- }
693
- if (settings.get("images.autoResize")) {
694
- try {
695
- const resized = await resizeImage({
696
- type: "image",
697
- data: imageData.data,
698
- mimeType: imageData.mimeType,
699
- });
700
- imageData = { type: "image", data: resized.data, mimeType: resized.mimeType };
701
- } catch {
702
- // Keep the normalized image when resize fails.
703
- }
704
- }
705
-
706
- const imageLink = (
707
- await materializeImageReferenceLinks(
708
- [
709
- {
710
- type: "image",
711
- data: imageData.data,
712
- mimeType: imageData.mimeType,
713
- },
714
- ],
715
- this.ctx.sessionManager.putBlob.bind(this.ctx.sessionManager),
716
- )
717
- )?.[0];
718
- this.ctx.pendingImages.push({
719
- type: "image",
720
- data: imageData.data,
721
- mimeType: imageData.mimeType,
722
- });
723
- this.ctx.pendingImageLinks.push(imageLink);
724
- this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
725
- // Insert placeholder at cursor like Claude does
726
- const imageNum = this.ctx.pendingImages.length;
727
- const placeholder = `[Image #${imageNum}]`;
728
- this.ctx.editor.insertText(`${placeholder} `);
729
- this.ctx.ui.requestRender();
730
- return true;
731
- }
732
- // No image in clipboard - show hint
733
- this.ctx.showStatus("No image in clipboard (use terminal paste for text)");
734
- return false;
776
+ },
777
+ `Unsupported clipboard image format: ${image.mimeType}`,
778
+ );
735
779
  } catch {
736
780
  this.ctx.showStatus("Failed to read clipboard");
737
781
  return false;
@@ -37,7 +37,9 @@ import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../
37
37
  import type { OAuthCredential } from "../../session/auth-storage";
38
38
  import { shortenPath } from "../../tools/render-utils";
39
39
  import { openPath } from "../../utils/open";
40
+ import { ChatBlock } from "../components/chat-block";
40
41
  import { MCPAddWizard } from "../components/mcp-add-wizard";
42
+ import { TranscriptBlock } from "../components/transcript-container";
41
43
  import { parseCommandArgs } from "../shared";
42
44
  import { theme } from "../theme/theme";
43
45
  import type { InteractiveModeContext } from "../types";
@@ -49,6 +51,42 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string)
49
51
  return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
50
52
  }
51
53
 
54
+ /**
55
+ * Animated "Connecting to …" transcript block. Owns its spinner interval: it
56
+ * starts on mount and is cleared on {@link ChatBlock.finish}/dispose, so callers
57
+ * never juggle `setInterval`/`clearInterval` or `requestRender` by hand.
58
+ */
59
+ class McpConnectingBlock extends ChatBlock {
60
+ readonly #text: Text;
61
+
62
+ constructor(private readonly serverName: string) {
63
+ super();
64
+ this.addChild(new Spacer(1));
65
+ const frame = theme.spinnerFrames[0] ?? "|";
66
+ this.#text = new Text(theme.fg("muted", `${frame} Connecting to "${serverName}"...`), 1, 0);
67
+ this.addChild(this.#text);
68
+ }
69
+
70
+ protected override onMount(): void {
71
+ const frames = theme.spinnerFrames;
72
+ let frame = 0;
73
+ const interval = setInterval(() => {
74
+ frame++;
75
+ this.#text.setText(
76
+ theme.fg("muted", `${frames[frame % frames.length] ?? "|"} Connecting to "${this.serverName}"...`),
77
+ );
78
+ this.requestRender();
79
+ }, 80);
80
+ this.onCleanup(() => clearInterval(interval));
81
+ }
82
+
83
+ /** Replace the spinner line with a terminal status; pair with {@link finish}. */
84
+ setStatus(text: string): void {
85
+ this.#text.setText(text);
86
+ this.requestRender();
87
+ }
88
+ }
89
+
52
90
  /**
53
91
  * Outcome of {@link MCPCommandController}'s OAuth handler.
54
92
  *
@@ -547,63 +585,45 @@ export class MCPCommandController {
547
585
  },
548
586
  {
549
587
  onAuth: (info: { url: string; instructions?: string }) => {
550
- // Show auth URL prominently in chat
551
- this.ctx.chatContainer.addChild(new Spacer(1));
552
- this.ctx.chatContainer.addChild(
553
- new Text(theme.fg("accent", "━━━ OAuth Authorization Required ━━━"), 1, 0),
554
- );
555
- this.ctx.chatContainer.addChild(new Spacer(1));
556
- this.ctx.chatContainer.addChild(
557
- new Text(theme.fg("muted", "Preparing browser authorization..."), 1, 0),
558
- );
559
- this.ctx.chatContainer.addChild(new Spacer(1));
560
- this.ctx.chatContainer.addChild(
588
+ // Show auth URL prominently in chat as one block
589
+ const block = new TranscriptBlock();
590
+ this.ctx.present(block);
591
+ block.addChild(new Text(theme.fg("accent", "━━━ OAuth Authorization Required ━━━"), 1, 0));
592
+ block.addChild(new Spacer(1));
593
+ block.addChild(new Text(theme.fg("muted", "Preparing browser authorization..."), 1, 0));
594
+ block.addChild(new Spacer(1));
595
+ block.addChild(
561
596
  new Text(
562
597
  theme.fg("muted", "Waiting for authorization... (Press Ctrl+C to cancel, 5 minute timeout)"),
563
598
  1,
564
599
  0,
565
600
  ),
566
601
  );
567
- this.ctx.chatContainer.addChild(new Spacer(1));
568
- this.ctx.chatContainer.addChild(
569
- new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0),
570
- );
571
- this.ctx.ui.requestRender();
602
+ block.addChild(new Spacer(1));
603
+ block.addChild(new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0));
572
604
  // Try to open browser automatically
573
605
  try {
574
606
  openPath(info.url);
575
607
 
576
608
  // Show confirmation that browser should open
577
- this.ctx.chatContainer.addChild(new Spacer(1));
578
- this.ctx.chatContainer.addChild(
579
- new Text(theme.fg("success", "→ Opening browser automatically..."), 1, 0),
580
- );
581
- this.ctx.chatContainer.addChild(new Spacer(1));
582
- this.ctx.chatContainer.addChild(
583
- new Text(theme.fg("muted", "Alternative if browser did not open:"), 1, 0),
584
- );
585
- this.ctx.chatContainer.addChild(
586
- new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0),
587
- );
588
- this.ctx.chatContainer.addChild(new Text(theme.fg("accent", info.url), 1, 0));
609
+ block.addChild(new Spacer(1));
610
+ block.addChild(new Text(theme.fg("success", "→ Opening browser automatically..."), 1, 0));
611
+ block.addChild(new Spacer(1));
612
+ block.addChild(new Text(theme.fg("muted", "Alternative if browser did not open:"), 1, 0));
613
+ block.addChild(new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0));
614
+ block.addChild(new Text(theme.fg("accent", info.url), 1, 0));
589
615
  this.ctx.ui.requestRender();
590
616
  } catch (_error) {
591
617
  // Show error if browser doesn't open
592
- this.ctx.chatContainer.addChild(new Spacer(1));
593
- this.ctx.chatContainer.addChild(
594
- new Text(theme.fg("warning", " Could not open browser automatically"), 1, 0),
595
- );
596
- this.ctx.chatContainer.addChild(
597
- new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0),
598
- );
599
- this.ctx.chatContainer.addChild(new Text(theme.fg("accent", info.url), 1, 0));
618
+ block.addChild(new Spacer(1));
619
+ block.addChild(new Text(theme.fg("warning", "→ Could not open browser automatically"), 1, 0));
620
+ block.addChild(new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0));
621
+ block.addChild(new Text(theme.fg("accent", info.url), 1, 0));
600
622
  this.ctx.ui.requestRender();
601
623
  }
602
624
  },
603
625
  onProgress: (message: string) => {
604
- this.ctx.chatContainer.addChild(new Spacer(1));
605
- this.ctx.chatContainer.addChild(new Text(theme.fg("muted", message), 1, 0));
606
- this.ctx.ui.requestRender();
626
+ this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]);
607
627
  },
608
628
  },
609
629
  );
@@ -611,9 +631,10 @@ export class MCPCommandController {
611
631
  // Execute OAuth flow with 5 minute timeout
612
632
  const credentials = await withTimeout(flow.login(), 5 * 60 * 1000, "OAuth flow timed out after 5 minutes");
613
633
 
614
- this.ctx.chatContainer.addChild(new Spacer(1));
615
- this.ctx.chatContainer.addChild(new Text(theme.fg("success", "✓ Authorization completed in browser."), 1, 0));
616
- this.ctx.ui.requestRender();
634
+ this.ctx.present([
635
+ new Spacer(1),
636
+ new Text(theme.fg("success", "✓ Authorization completed in browser."), 1, 0),
637
+ ]);
617
638
 
618
639
  // Generate a unique credential ID
619
640
  const credentialId = `mcp_oauth_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
@@ -766,19 +787,8 @@ export class MCPCommandController {
766
787
  ): Promise<"connected" | "connecting" | "disconnected"> {
767
788
  if (!this.ctx.mcpManager) return "disconnected";
768
789
 
769
- this.ctx.chatContainer.addChild(new Spacer(1));
770
- const frames = theme.spinnerFrames;
771
- const initialFrame = frames[0] ?? "|";
772
- const statusText = new Text(theme.fg("muted", `${initialFrame} Connecting to "${name}"...`), 1, 0);
773
- this.ctx.chatContainer.addChild(statusText);
774
- this.ctx.ui.requestRender();
775
-
776
- let frame = 0;
777
- const interval = setInterval(() => {
778
- statusText.setText(theme.fg("muted", `${frames[frame % frames.length]} Connecting to "${name}"...`));
779
- frame++;
780
- this.ctx.ui.requestRender();
781
- }, 80);
790
+ const block = new McpConnectingBlock(name);
791
+ this.ctx.present(block);
782
792
 
783
793
  try {
784
794
  try {
@@ -792,20 +802,19 @@ export class MCPCommandController {
792
802
  await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
793
803
  }
794
804
  if (state === "connected") {
795
- statusText.setText(theme.fg("success", `✓ Connected to "${name}"`));
805
+ block.setStatus(theme.fg("success", `✓ Connected to "${name}"`));
796
806
  } else if (state === "connecting") {
797
- statusText.setText(theme.fg("muted", `◌ "${name}" is still connecting...`));
807
+ block.setStatus(theme.fg("muted", `◌ "${name}" is still connecting...`));
798
808
  } else {
799
- statusText.setText(
809
+ block.setStatus(
800
810
  options?.suppressDisconnectedWarning
801
811
  ? theme.fg("muted", `◌ Connection check complete for "${name}"`)
802
812
  : theme.fg("warning", `⚠ Could not connect to "${name}" yet`),
803
813
  );
804
814
  }
805
- this.ctx.ui.requestRender();
806
815
  return state;
807
816
  } finally {
808
- clearInterval(interval);
817
+ block.finish();
809
818
  }
810
819
  }
811
820