@oh-my-pi/pi-coding-agent 17.1.3 → 17.1.4

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 (100) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli.js +3759 -3751
  3. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +1 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  5. package/dist/types/cli/update-cli.d.ts +41 -0
  6. package/dist/types/cli/usage-cli.d.ts +4 -2
  7. package/dist/types/commands/update.d.ts +1 -0
  8. package/dist/types/config/model-registry.d.ts +19 -0
  9. package/dist/types/config/settings-schema.d.ts +30 -7
  10. package/dist/types/cursor.d.ts +47 -1
  11. package/dist/types/eval/js/process-entry.d.ts +2 -1
  12. package/dist/types/eval/js/worker-core.d.ts +4 -1
  13. package/dist/types/extensibility/extensions/runner.d.ts +1 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +4 -0
  16. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +15 -7
  17. package/dist/types/extensibility/shared-events.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +9 -0
  19. package/dist/types/modes/components/custom-editor.d.ts +12 -8
  20. package/dist/types/plan-mode/approved-plan.d.ts +3 -2
  21. package/dist/types/secrets/obfuscator.d.ts +7 -31
  22. package/dist/types/session/agent-session.d.ts +19 -4
  23. package/dist/types/session/prewalk.d.ts +2 -1
  24. package/dist/types/session/session-advisors.d.ts +8 -0
  25. package/dist/types/session/session-metadata.d.ts +29 -0
  26. package/dist/types/session/turn-recovery.d.ts +5 -5
  27. package/dist/types/stt/sherpa-runtime.d.ts +38 -0
  28. package/dist/types/thinking.d.ts +24 -0
  29. package/dist/types/tools/bash.d.ts +5 -4
  30. package/dist/types/tools/computer/exposure.d.ts +8 -0
  31. package/dist/types/tools/computer/supervisor.d.ts +1 -1
  32. package/dist/types/tools/computer/worker-entry.d.ts +1 -1
  33. package/dist/types/tools/computer/worker.d.ts +1 -1
  34. package/dist/types/tools/computer.d.ts +6 -0
  35. package/dist/types/tools/shell-tokenize.d.ts +14 -0
  36. package/dist/types/tools/todo.d.ts +7 -1
  37. package/package.json +12 -12
  38. package/src/advisor/__tests__/advisor.test.ts +80 -10
  39. package/src/advisor/runtime.ts +27 -1
  40. package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
  41. package/src/cli/auth-gateway-cli.ts +63 -16
  42. package/src/cli/config-cli.ts +25 -7
  43. package/src/cli/update-cli.ts +229 -29
  44. package/src/cli/usage-cli.ts +144 -15
  45. package/src/cli.ts +21 -15
  46. package/src/commands/update.ts +6 -0
  47. package/src/config/__tests__/model-registry.test.ts +42 -7
  48. package/src/config/model-registry.ts +67 -4
  49. package/src/config/settings-schema.ts +39 -8
  50. package/src/config/settings.ts +24 -2
  51. package/src/cursor.ts +153 -0
  52. package/src/dap/session.ts +70 -11
  53. package/src/eval/__tests__/js-context-manager.test.ts +9 -1
  54. package/src/eval/__tests__/process-entry-import.test.ts +111 -1
  55. package/src/eval/js/process-entry.ts +9 -5
  56. package/src/eval/js/worker-core.ts +6 -2
  57. package/src/exec/bash-executor.ts +4 -2
  58. package/src/extensibility/extensions/runner.ts +23 -8
  59. package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
  60. package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
  61. package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
  62. package/src/extensibility/shared-events.ts +2 -0
  63. package/src/main.ts +22 -1
  64. package/src/modes/acp/acp-agent.ts +6 -0
  65. package/src/modes/components/assistant-message.ts +88 -11
  66. package/src/modes/components/chat-transcript-builder.ts +2 -0
  67. package/src/modes/components/custom-editor.test.ts +170 -0
  68. package/src/modes/components/custom-editor.ts +79 -29
  69. package/src/modes/components/settings-defs.ts +5 -1
  70. package/src/modes/controllers/event-controller.ts +96 -4
  71. package/src/modes/controllers/selector-controller.ts +14 -0
  72. package/src/modes/interactive-mode.ts +34 -8
  73. package/src/modes/utils/context-usage.ts +19 -2
  74. package/src/modes/utils/interactive-context-helpers.ts +1 -0
  75. package/src/plan-mode/approved-plan.ts +18 -10
  76. package/src/prompts/system/custom-system-prompt.md +1 -1
  77. package/src/prompts/system/system-prompt.md +10 -1
  78. package/src/sdk.ts +4 -0
  79. package/src/secrets/obfuscator.ts +36 -126
  80. package/src/session/agent-session.ts +69 -60
  81. package/src/session/prewalk.ts +8 -3
  82. package/src/session/session-advisors.ts +67 -3
  83. package/src/session/session-metadata.ts +53 -0
  84. package/src/session/session-provider-boundary.ts +0 -1
  85. package/src/session/session-tools.ts +35 -1
  86. package/src/session/turn-recovery.ts +36 -19
  87. package/src/slash-commands/builtin-registry.ts +49 -7
  88. package/src/stt/asr-worker.ts +2 -37
  89. package/src/stt/sherpa-runtime.ts +71 -0
  90. package/src/task/executor.ts +6 -5
  91. package/src/thinking.ts +39 -0
  92. package/src/tools/bash.ts +43 -15
  93. package/src/tools/computer/exposure.ts +38 -0
  94. package/src/tools/computer/supervisor.ts +61 -13
  95. package/src/tools/computer/worker-entry.ts +28 -19
  96. package/src/tools/computer/worker.ts +3 -7
  97. package/src/tools/computer.ts +65 -10
  98. package/src/tools/gh-cache-invalidation.ts +2 -82
  99. package/src/tools/shell-tokenize.ts +83 -0
  100. package/src/tools/todo.ts +44 -17
@@ -25,6 +25,8 @@ interface ActiveRun {
25
25
 
26
26
  type RunResult = Extract<WorkerOutbound, { type: "result" }>;
27
27
 
28
+ export type RejectionInterceptor = (handler: (reason: unknown) => boolean) => () => void;
29
+
28
30
  export type WorkerCoreOptions =
29
31
  | {
30
32
  mode: "isolated";
@@ -36,10 +38,12 @@ export type WorkerCoreOptions =
36
38
  * mutate the host's own cwd on the inline fallback.
37
39
  */
38
40
  chdir?: (cwd: string) => void;
41
+ /** Share the subprocess host's fatal-rejection guard when one is installed. */
42
+ interceptUnhandledRejections?: RejectionInterceptor;
39
43
  }
40
44
  | {
41
45
  mode: "inline";
42
- interceptUnhandledRejections(handler: (reason: unknown) => boolean): () => void;
46
+ interceptUnhandledRejections: RejectionInterceptor;
43
47
  };
44
48
 
45
49
  /** Finished-cell filenames retained for attributing rejections that surface after the run settled. */
@@ -115,7 +119,7 @@ export class WorkerCore {
115
119
  * without a usable stack, while anything else keeps its default fatality.
116
120
  */
117
121
  #installRejectionGuard(): () => void {
118
- if (this.#options.mode === "inline") {
122
+ if (this.#options.interceptUnhandledRejections) {
119
123
  return this.#options.interceptUnhandledRejections(reason => this.#consumeRejection(reason));
120
124
  }
121
125
  const onRejection = (reason: unknown): void => {
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { ExponentialYield } from "@oh-my-pi/pi-agent-core/utils/yield";
7
7
  import { type MinimizerOptions, Shell, type ShellRunResult } from "@oh-my-pi/pi-natives";
8
- import { isExecutable, type ShellConfig } from "@oh-my-pi/pi-utils/procmgr";
8
+ import { isCmdShell, isExecutable, type ShellConfig } from "@oh-my-pi/pi-utils/procmgr";
9
9
  import { Settings, type ShellMinimizerSettings } from "../config/settings";
10
10
  import { OutputSink } from "../session/streaming-output";
11
11
  import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
@@ -367,8 +367,10 @@ export async function executeBash(command: string, options?: BashExecutorOptions
367
367
  });
368
368
  const commandEnv = buildNonInteractiveEnv(preflight.env);
369
369
  const runCdInPersistentShell = options?.useUserShell === true && !prefix && isPersistentShellCdCommand(command);
370
+ // Never wrap in cmd.exe: it is only the Windows no-bash fallback for spawn
371
+ // paths, and the embedded brush shell runs the POSIX line better directly.
370
372
  const finalCommand =
371
- options?.useUserShell === true && !bashShell && !runCdInPersistentShell
373
+ options?.useUserShell === true && !bashShell && !isCmdShell(shell) && !runCdInPersistentShell
372
374
  ? buildUserShellCommand(shell, args, preflight.command)
373
375
  : preflight.command;
374
376
 
@@ -103,10 +103,11 @@ function handlerTimeoutForEvent(eventType: string): number {
103
103
  }
104
104
 
105
105
  const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
106
+ const EXTENSION_HANDLER_ABORTED = Symbol("extensionHandlerAborted");
106
107
 
107
108
  /**
108
- * Race `work` against a `timeoutMs` budget, clearing the pending timer the
109
- * instant the work settles.
109
+ * Race `work` against a `timeoutMs` budget and optional cancellation signal,
110
+ * clearing the timer and abort listener as soon as one branch settles.
110
111
  *
111
112
  * We deliberately avoid `Bun.sleep(timeoutMs).then(...)` here: that leaves an
112
113
  * uncancellable timer registered with the event loop, so every successful
@@ -119,14 +120,20 @@ const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
119
120
  async function raceHandlerWithTimeout<T>(
120
121
  work: Promise<T>,
121
122
  timeoutMs: number,
122
- ): Promise<T | typeof EXTENSION_HANDLER_TIMEOUT> {
123
- const { promise: timeoutPromise, resolve: resolveTimeout } =
124
- Promise.withResolvers<typeof EXTENSION_HANDLER_TIMEOUT>();
125
- const timer = setTimeout(() => resolveTimeout(EXTENSION_HANDLER_TIMEOUT), timeoutMs);
123
+ signal?: AbortSignal,
124
+ ): Promise<T | typeof EXTENSION_HANDLER_TIMEOUT | typeof EXTENSION_HANDLER_ABORTED> {
125
+ if (signal?.aborted) return EXTENSION_HANDLER_ABORTED;
126
+ const { promise: interruptPromise, resolve: resolveInterrupt } = Promise.withResolvers<
127
+ typeof EXTENSION_HANDLER_TIMEOUT | typeof EXTENSION_HANDLER_ABORTED
128
+ >();
129
+ const timer = setTimeout(() => resolveInterrupt(EXTENSION_HANDLER_TIMEOUT), timeoutMs);
130
+ const onAbort = () => resolveInterrupt(EXTENSION_HANDLER_ABORTED);
131
+ signal?.addEventListener("abort", onAbort, { once: true });
126
132
  try {
127
- return await Promise.race([work, timeoutPromise]);
133
+ return await Promise.race([work, interruptPromise]);
128
134
  } finally {
129
135
  clearTimeout(timer);
136
+ signal?.removeEventListener("abort", onAbort);
130
137
  }
131
138
  }
132
139
 
@@ -372,7 +379,9 @@ export class ExtensionRunner {
372
379
  await this.emit({ type: "credential_disabled", ...event });
373
380
  }
374
381
 
382
+ /** Emits a session stop pass that can be cancelled with the active settle signal. */
375
383
  async emitSessionStop(event: Omit<SessionStopEvent, "type">): Promise<SessionStopEventResult | undefined> {
384
+ if (event.signal.aborted) return undefined;
376
385
  return await this.emit({ type: "session_stop", ...event });
377
386
  }
378
387
 
@@ -622,8 +631,14 @@ export class ExtensionRunner {
622
631
  ext: Extension,
623
632
  timeoutMs: number,
624
633
  ): Promise<TResult | undefined> {
634
+ const signal =
635
+ event.type === "session_stop" && "signal" in event && event.signal instanceof AbortSignal
636
+ ? event.signal
637
+ : undefined;
638
+ if (signal?.aborted) return undefined;
625
639
  try {
626
- const handlerResult = await raceHandlerWithTimeout(Promise.resolve(handler(event, ctx)), timeoutMs);
640
+ const handlerResult = await raceHandlerWithTimeout(Promise.resolve(handler(event, ctx)), timeoutMs, signal);
641
+ if (handlerResult === EXTENSION_HANDLER_ABORTED) return undefined;
627
642
  if (handlerResult === EXTENSION_HANDLER_TIMEOUT) {
628
643
  const error = `handler timed out after ${timeoutMs}ms`;
629
644
  logger.warn("Extension handler timed out", {
@@ -19,6 +19,9 @@
19
19
  * `types.ts` via the `export *` below — pi-ai still exports both as types,
20
20
  * only the runtime `Type` builder and `StringEnum()` helper were removed.
21
21
  */
22
+ import type { Api, Model } from "@oh-my-pi/pi-ai";
23
+ import type { Effort } from "@oh-my-pi/pi-catalog/effort";
24
+ import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
22
25
  import {
23
26
  calculateCost,
24
27
  getBundledModel,
@@ -71,6 +74,12 @@ export function StringEnum<T extends string | number>(
71
74
  return schema;
72
75
  }
73
76
 
77
+ /** Clamp a historical Pi thinking level against OMP's model metadata. */
78
+ export function clampThinkingLevel<TApi extends Api>(model: Model<TApi>, level: Effort | "off"): Effort | "off" {
79
+ if (level === "off") return "off";
80
+ return clampThinkingLevelForModel(model, level) ?? "off";
81
+ }
82
+
74
83
  export * from "@oh-my-pi/pi-ai";
75
84
  /**
76
85
  * Compatibility re-exports for catalog symbols that pi-ai historically exposed
@@ -861,6 +861,11 @@ export interface ResourceLoader {
861
861
  readonly __ompLegacyPiLoader?: true;
862
862
  }
863
863
 
864
+ /** Create a pre-initialization runtime for legacy extension resource loaders. */
865
+ export function createExtensionRuntime(): ExtensionRuntime {
866
+ return new ExtensionRuntime();
867
+ }
868
+
864
869
  /**
865
870
  * Loader-owned inputs that {@link createAgentSession} needs regardless of
866
871
  * whether the caller provided extra options. `cwd`/`agentDir` fall back to
@@ -894,7 +899,7 @@ export class DefaultResourceLoader implements ResourceLoader {
894
899
  readonly __ompLegacyPiLoader = true as const;
895
900
  #state: ResolvedLoaderState;
896
901
  #options: DefaultResourceLoaderOptions;
897
- #extensionsResult: LoadExtensionsResult = { extensions: [], errors: [], runtime: new ExtensionRuntime() };
902
+ #extensionsResult: LoadExtensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };
898
903
  #skills: Skill[] = [];
899
904
  #skillDiagnostics: ResourceDiagnostic[] = [];
900
905
  #prompts: PromptTemplate[] = [];
@@ -1043,7 +1048,7 @@ export class DefaultResourceLoader implements ResourceLoader {
1043
1048
  const { cwd, noExtensions, additionalExtensionPaths, extensionFactories, eventBus } = this.#state;
1044
1049
 
1045
1050
  if (noExtensions && additionalExtensionPaths.length === 0 && extensionFactories.length === 0) {
1046
- return { extensions: [], errors: [], runtime: new ExtensionRuntime() };
1051
+ return { extensions: [], errors: [], runtime: createExtensionRuntime() };
1047
1052
  }
1048
1053
 
1049
1054
  const paths = await discoverSessionExtensionPaths(
@@ -1366,6 +1371,13 @@ export function getPackageDir(): string {
1366
1371
  return getOmpPackageDir() ?? (isCompiledBinary() ? path.dirname(process.execPath) : process.cwd());
1367
1372
  }
1368
1373
 
1374
+ // Legacy pi's `@earendil-works/pi-coding-agent` re-exported `estimateTokens`
1375
+ // from its package root (via `./core/compaction/index.ts`). In omp it lives in
1376
+ // `@oh-my-pi/pi-agent-core/compaction`, and the coding-agent barrel below does
1377
+ // not forward it, so legacy extensions importing it fail Bun's static export
1378
+ // check during validation (issue #6583).
1379
+ export { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
1380
+
1369
1381
  export * from "../index";
1370
1382
  export { formatBytes as formatSize } from "../tools/render-utils";
1371
1383
  export { copyToClipboard } from "../utils/clipboard";
@@ -6,5 +6,38 @@
6
6
  * exposes the equivalent, broader `decodePrintableKey` helper. Keep the legacy
7
7
  * name available without reintroducing it into the canonical package surface.
8
8
  */
9
+ import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui";
10
+
9
11
  export * from "@oh-my-pi/pi-tui";
10
12
  export { decodePrintableKey as decodeKittyPrintable } from "@oh-my-pi/pi-tui";
13
+
14
+ /** Report canonical terminal capabilities through the legacy Pi TUI shape. */
15
+ export function getCapabilities(): {
16
+ images: "kitty" | "iterm2" | null;
17
+ trueColor: boolean;
18
+ hyperlinks: boolean;
19
+ } {
20
+ const images =
21
+ TERMINAL.imageProtocol === ImageProtocol.Kitty
22
+ ? "kitty"
23
+ : TERMINAL.imageProtocol === ImageProtocol.Iterm2
24
+ ? "iterm2"
25
+ : null;
26
+ return { images, trueColor: TERMINAL.trueColor, hyperlinks: TERMINAL.hyperlinks };
27
+ }
28
+
29
+ /**
30
+ * Delete one Kitty graphics image by id, matching the legacy Pi TUI helper.
31
+ *
32
+ * Returns the bare control sequence exactly like upstream Pi: legacy callers
33
+ * (e.g. pi-sprite) apply their own tmux passthrough wrapping, so wrapping here
34
+ * would double-wrap under tmux and the outer terminal would drop the command.
35
+ */
36
+ export function deleteKittyImage(imageId: number): string {
37
+ return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`;
38
+ }
39
+
40
+ /** Delete every Kitty graphics image using the legacy Pi TUI bare sequence. */
41
+ export function deleteAllKittyImages(): string {
42
+ return "\x1b_Ga=d,d=A,q=2\x1b\\";
43
+ }
@@ -102,6 +102,8 @@ export interface SessionStopEvent {
102
102
  session_id: string;
103
103
  session_file?: string;
104
104
  stop_hook_active: boolean;
105
+ /** Cancels handler waiting when the active settle pass is aborted. */
106
+ signal: AbortSignal;
105
107
  }
106
108
 
107
109
  /** Preparation data for tree navigation (used by session_before_tree event) */
package/src/main.ts CHANGED
@@ -885,6 +885,10 @@ export async function buildSessionOptions(
885
885
  // - supports --provider <name> --model <pattern>
886
886
  // - supports --model <provider>/<pattern>
887
887
  const modelMatchPreferences = getModelMatchPreferences(activeSettings);
888
+ // True when a configured `default` role was deliberately left unresolved for
889
+ // createAgentSession's post-extension re-resolution (issue #6694); the
890
+ // scoped thinking-level seed below must be deferred along with the model.
891
+ let deferredDefaultRole = false;
888
892
  if (parsed.model) {
889
893
  const resolved = resolveCliModel({
890
894
  cliProvider: parsed.provider,
@@ -946,7 +950,20 @@ export async function buildSessionOptions(
946
950
  }
947
951
  }
948
952
  }
949
- if (!options.model) options.model = scopedModels[0].model;
953
+ // A configured `default` role that doesn't resolve within the startup
954
+ // scope is deferred, NOT silently pinned to `scopedModels[0]`: the scope
955
+ // is resolved before extensions register their providers, so a role naming
956
+ // an extension-registered model (listed in `enabledModels`) would drop out
957
+ // here and the session would run on an unrelated in-scope provider without
958
+ // any error. Leaving `options.model` unset lets createAgentSession's
959
+ // post-extension default-role resolution reclaim it against the fully
960
+ // registered, still enabledModels-scoped catalog (issue #6694).
961
+ // Defer ONLY for a settings-derived scope: createAgentSession re-resolves
962
+ // against `settings.enabledModels` and never sees CLI `--models`, so
963
+ // deferring under an explicit CLI scope would let the saved default
964
+ // escape it — keep pinning the first scoped model there.
965
+ deferredDefaultRole = !options.model && Boolean(remembered) && !((parsed.models?.length ?? 0) > 0);
966
+ if (!options.model && !deferredDefaultRole) options.model = scopedModels[0].model;
950
967
  }
951
968
 
952
969
  if (parsed.noPrewalk && (parsed.prewalk || parsed.prewalkInto !== undefined)) {
@@ -1005,6 +1022,10 @@ export async function buildSessionOptions(
1005
1022
  } else if (
1006
1023
  scopedModels.length > 0 &&
1007
1024
  scopedModels[0].explicitThinkingLevel === true &&
1025
+ // A deferred default role resolves its own model (and any explicit
1026
+ // thinking suffix) after extensions register; seeding the fallback
1027
+ // scoped model's level here would override it in createAgentSession.
1028
+ !deferredDefaultRole &&
1008
1029
  !parsed.continue &&
1009
1030
  !parsed.resume
1010
1031
  ) {
@@ -1691,6 +1691,12 @@ export class AcpAgent implements Agent {
1691
1691
  planExists: true,
1692
1692
  };
1693
1693
  if (!approved) {
1694
+ // Rejection keeps plan mode active for another planning turn. Promote the
1695
+ // reviewed path into plan-mode state so the next `#buildPlanModeMessage()`
1696
+ // targets the plan just reviewed, not the stale state path.
1697
+ if (state.planFilePath !== planFilePath) {
1698
+ session.setPlanModeState({ ...state, planFilePath });
1699
+ }
1694
1700
  const normalizedTitle = normalizePlanTitle(resolvedTitle).title;
1695
1701
  return {
1696
1702
  content: [
@@ -1,10 +1,20 @@
1
1
  import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
2
- import { Container, Image, type ImageBudget, ImageProtocol, Markdown, Spacer, TERMINAL, Text } from "@oh-my-pi/pi-tui";
2
+ import {
3
+ Container,
4
+ Image,
5
+ type ImageBudget,
6
+ ImageProtocol,
7
+ Markdown,
8
+ replaceTabs,
9
+ Spacer,
10
+ TERMINAL,
11
+ Text,
12
+ } from "@oh-my-pi/pi-tui";
3
13
  import { formatNumber } from "@oh-my-pi/pi-utils";
4
14
  import chalk from "chalk";
5
15
  import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
6
16
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
7
- import { getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils";
17
+ import { expandKeyHint, getPreviewLines, resolveImageOptions, TRUNCATE_LENGTHS } from "../../tools/render-utils";
8
18
  import { canonicalizeMessage, formatThinkingForDisplay, hasDisplayableThinking } from "../../utils/thinking-display";
9
19
  import { resolveAssistantErrorPresentation } from "../utils/transcript-render-helpers";
10
20
  import { type CacheInvalidation, CacheInvalidationMarkerComponent } from "./cache-invalidation-marker";
@@ -196,6 +206,22 @@ export class AssistantMessageComponent extends Container {
196
206
  * transcript keeps the error in history.
197
207
  */
198
208
  #errorPinned = false;
209
+ /**
210
+ * Whether the inline turn-ending error block renders its full body instead of
211
+ * the {@link MAX_TRANSCRIPT_ERROR_LINES}-capped preview. Toggled by
212
+ * {@link setExpanded} so Ctrl+O (tool-output expansion) reveals a long
213
+ * provider error whose tail would otherwise be unreachable in the live TUI.
214
+ */
215
+ #errorExpanded = false;
216
+ /**
217
+ * True when the current {@link updateContent} message carries a truncatable
218
+ * inline provider error (the `#appendErrorBlock` path) — set whether or not
219
+ * the inline block was actually drawn, so it stays true even while the error
220
+ * is suppressed under a pinned banner. Gates {@link setExpanded} so toggling
221
+ * expansion only re-renders assistant turns that carry such an error, not
222
+ * every message in the transcript.
223
+ */
224
+ #hasTruncatableError = false;
199
225
  /**
200
226
  * Monotonic content version reported to the transcript container via
201
227
  * {@link getTranscriptBlockVersion}. Bumped by {@link updateContent} — the
@@ -408,6 +434,22 @@ export class AssistantMessageComponent extends Container {
408
434
  }
409
435
  }
410
436
 
437
+ /**
438
+ * Expand or collapse the inline turn-ending error block so Ctrl+O
439
+ * (tool-output expansion) can reveal a long provider error's hidden tail.
440
+ * Only re-renders when the current message carries a truncatable error, so
441
+ * toggling expansion across the transcript skips ordinary turns. Works even
442
+ * while the error is pinned in the banner: the inline block is drawn (in full)
443
+ * when expanded so the complete body is reachable without sending a message.
444
+ */
445
+ setExpanded(expanded: boolean): void {
446
+ if (this.#errorExpanded === expanded) return;
447
+ this.#errorExpanded = expanded;
448
+ if (this.#hasTruncatableError && this.#lastMessage) {
449
+ this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
450
+ }
451
+ }
452
+
411
453
  isTranscriptBlockFinalized(): boolean {
412
454
  return this.#transcriptBlockFinalized;
413
455
  }
@@ -488,12 +530,25 @@ export class AssistantMessageComponent extends Container {
488
530
  }
489
531
 
490
532
  /**
491
- * Render a turn-ending provider error inline. Drops blank lines, clamps the
492
- * line count to {@link MAX_TRANSCRIPT_ERROR_LINES}, and width-truncates each
493
- * line so a pathological body — e.g. the HTML page a proxy returns on a 502 —
494
- * can't flood the transcript. Mirrors {@link ErrorBannerComponent}.
533
+ * Render a turn-ending provider error inline. Collapsed (default), it drops
534
+ * blank lines, clamps the line count to {@link MAX_TRANSCRIPT_ERROR_LINES},
535
+ * and width-truncates each line so a pathological body — e.g. the HTML page a
536
+ * proxy returns on a 502 — can't flood the transcript, appending a dim
537
+ * `ctrl+o`/expand hint when lines were hidden. Expanded (via
538
+ * {@link setExpanded}), it renders the full body — tabs replaced, blank lines
539
+ * preserved — letting {@link Text} word-wrap each line to the render width so
540
+ * the complete message is reachable. Mirrors {@link ErrorBannerComponent}.
495
541
  */
496
542
  #appendErrorBlock(message: string): void {
543
+ if (this.#errorExpanded) {
544
+ const [first = "Unknown error", ...rest] = replaceTabs(message.replace(/\s+$/, "")).split("\n");
545
+ this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${first}`), 1, 0));
546
+ for (const line of rest) {
547
+ this.#contentContainer.addChild(new Text(theme.fg("error", ` ${line}`), 1, 0));
548
+ }
549
+ return;
550
+ }
551
+ const total = message.split("\n").filter(l => l.trim()).length;
497
552
  const lines = getPreviewLines(message, MAX_TRANSCRIPT_ERROR_LINES, TRUNCATE_LENGTHS.LINE);
498
553
  if (lines.length === 0) lines.push("Unknown error");
499
554
  // The caller owns the separating Spacer; adding one here doubled the gap.
@@ -501,6 +556,16 @@ export class AssistantMessageComponent extends Container {
501
556
  for (const line of lines.slice(1)) {
502
557
  this.#contentContainer.addChild(new Text(theme.fg("error", ` ${line}`), 1, 0));
503
558
  }
559
+ if (total > lines.length) {
560
+ const hidden = total - lines.length;
561
+ this.#contentContainer.addChild(
562
+ new Text(
563
+ theme.fg("dim", ` … +${hidden} more line${hidden === 1 ? "" : "s"} (${expandKeyHint()} to expand)`),
564
+ 1,
565
+ 0,
566
+ ),
567
+ );
568
+ }
504
569
  }
505
570
 
506
571
  /** Toggle rendering for assistant-native and tool-result images. */
@@ -643,7 +708,10 @@ export class AssistantMessageComponent extends Container {
643
708
  if (this.#toolImagesByCallId.size > 0) return false;
644
709
  const errorPresentation = resolveAssistantErrorPresentation(message);
645
710
  if (errorPresentation.kind === "compact-recovered") return false;
646
- if (errorPresentation.kind === "full" && !(message.stopReason === "error" && this.#errorPinned)) {
711
+ if (
712
+ errorPresentation.kind === "full" &&
713
+ !(message.stopReason === "error" && this.#errorPinned && !this.#errorExpanded)
714
+ ) {
647
715
  return false;
648
716
  }
649
717
  // Extension stability: if thinking renderers exist and any tracked thinking
@@ -777,6 +845,7 @@ export class AssistantMessageComponent extends Container {
777
845
  // Clear content container
778
846
  this.#contentContainer.clear();
779
847
  this.#thinkingDots = undefined;
848
+ this.#hasTruncatableError = false;
780
849
 
781
850
  // Determine if we should capture Markdown instances for next fast path
782
851
  const shouldCapture = this.#canFastPath(message);
@@ -859,11 +928,19 @@ export class AssistantMessageComponent extends Container {
859
928
  this.#contentContainer.addChild(new Spacer(1));
860
929
  this.#contentContainer.addChild(new Text(theme.fg("dim", errorPresentation.text), 1, 0));
861
930
  } else if (!hasToolCalls && errorPresentation.kind === "full") {
862
- if (!(message.stopReason === "error" && this.#errorPinned)) {
931
+ if (message.stopReason === "aborted") {
863
932
  this.#contentContainer.addChild(new Spacer(1));
864
- if (message.stopReason === "aborted") {
865
- this.#contentContainer.addChild(new Text(theme.fg("error", errorPresentation.text), 1, 0));
866
- } else {
933
+ this.#contentContainer.addChild(new Text(theme.fg("error", errorPresentation.text), 1, 0));
934
+ } else {
935
+ // Non-aborted provider error: a truncatable inline block. Mark it so
936
+ // setExpanded re-renders even while the same error is pinned above.
937
+ this.#hasTruncatableError = true;
938
+ // Suppress the inline block only while pinned AND collapsed — the
939
+ // banner already shows the capped error there. When expanded, draw
940
+ // the inline block in full so the complete body is reachable without
941
+ // sending a message; the pinned banner stays a short reminder.
942
+ if (!(message.stopReason === "error" && this.#errorPinned) || this.#errorExpanded) {
943
+ this.#contentContainer.addChild(new Spacer(1));
867
944
  this.#appendErrorBlock(errorPresentation.text);
868
945
  }
869
946
  }
@@ -303,6 +303,7 @@ export class ChatTranscriptBuilder {
303
303
  proseOnlyThinking,
304
304
  );
305
305
  assistantComponent.setImagesVisible(settings.get("terminal.showImages"));
306
+ this.#trackExpandable(assistantComponent);
306
307
  this.container.addChild(assistantComponent);
307
308
 
308
309
  if (settings.get("display.cacheMissMarker")) {
@@ -334,6 +335,7 @@ export class ChatTranscriptBuilder {
334
335
  proseOnlyThinking,
335
336
  );
336
337
  component.setImagesVisible(settings.get("terminal.showImages"));
338
+ this.#trackExpandable(component);
337
339
  this.container.addChild(component);
338
340
  };
339
341
 
@@ -8,6 +8,7 @@ import {
8
8
  CustomEditor,
9
9
  extractBracketedImagePastePaths,
10
10
  extractBracketedPastePaths,
11
+ extractImagePastePathsFromText,
11
12
  extractImagePathFromText,
12
13
  extractPastePathsFromText,
13
14
  SPACE_HOLD_MECHANICAL_RUN,
@@ -182,6 +183,43 @@ describe("CustomEditor bracketed path paste", () => {
182
183
  expect(editor.getText()).toBe("/tmp/report.csv");
183
184
  expect(imagePathCalls).toBe(0);
184
185
  });
186
+
187
+ it("attaches a spaced screenshot path as an image instead of inserting it as literal text", () => {
188
+ // #6578: the raw macOS "copy screenshot path" payload has unescaped
189
+ // spaces, which defeats the segment splitter. Before the whole-text
190
+ // fallback the paste degraded to literal text in the prompt.
191
+ const { editor } = makeEditor();
192
+ const screenshot = "/Users/me/Desktop/Screenshot 2026-07-24 at 1.55.12 PM.png";
193
+ const pasted: string[] = [];
194
+ editor.onPasteImagePath = path => {
195
+ pasted.push(path);
196
+ };
197
+
198
+ editor.handleInput(bracketedPaste(screenshot));
199
+
200
+ expect(pasted).toEqual([screenshot]);
201
+ expect(editor.getText()).toBe("");
202
+ });
203
+
204
+ it("keeps a two-file drag with unescaped spaces as text instead of attaching one fused path", () => {
205
+ // PR #6582 review: selecting two screenshots and dropping them together
206
+ // emits a single space-separated payload the splitter also refuses
207
+ // (`PM.png` carries no directory). Fusing it into one path attaches
208
+ // nothing — `handleImagePathPaste` hits ENOENT and only shows a status,
209
+ // never restoring the text — so the drop must degrade to a text paste.
210
+ const { editor } = makeEditor();
211
+ const dropped =
212
+ "/Users/me/Desktop/Screenshot 2026-07-24 at 1.55.12 PM.png /Users/me/Desktop/Screenshot 2026-07-24 at 1.56.00 PM.png";
213
+ const pasted: string[] = [];
214
+ editor.onPasteImagePath = path => {
215
+ pasted.push(path);
216
+ };
217
+
218
+ editor.handleInput(bracketedPaste(dropped));
219
+
220
+ expect(pasted).toEqual([]);
221
+ expect(editor.getText()).toBe(dropped);
222
+ });
185
223
  });
186
224
  describe("CustomEditor configured paste image keys", () => {
187
225
  it("routes Ghostty Cmd+V kitty key events through the macOS image-paste default", () => {
@@ -250,6 +288,12 @@ describe("extractImagePathFromText (issue #3506)", () => {
250
288
  );
251
289
  });
252
290
 
291
+ it("returns undefined for two spaced paths the splitter could not separate", () => {
292
+ // Only the whole-text pass survives the splitter here, and it must not
293
+ // fuse the pair into one path the loader can never resolve.
294
+ expect(extractImagePathFromText("/tmp/a.png /tmp/b shot.png")).toBeUndefined();
295
+ });
296
+
253
297
  it("does not hijack prose that happens to contain a path-shaped fragment", () => {
254
298
  // The whole-text branch is gated on ABSOLUTE_PATH_PREFIX_REGEX, so a
255
299
  // non-anchored prefix ("see ...") never triggers it.
@@ -264,6 +308,132 @@ describe("extractPastePathsFromText", () => {
264
308
  });
265
309
  });
266
310
 
311
+ describe("extractImagePastePathsFromText (issue #6578)", () => {
312
+ const MAC_SCREENSHOT =
313
+ "/var/folders/xx/T/TemporaryItems/NSIRD_screencaptureui_ab/Screenshot 2026-07-24 at 1.55.12 PM.png";
314
+ const WINDOWS_SPACED = "C:\\Users\\me\\My Pictures\\shot 1.png";
315
+
316
+ // Every case must resolve identically on the stripped-marker route
317
+ // (assembled pastes) and the bracketed route, since the latter now
318
+ // delegates to the former.
319
+ const cases: { name: string; text: string; expected: string[] | undefined }[] = [
320
+ { name: "a macOS screenshot path with unescaped spaces", text: MAC_SCREENSHOT, expected: [MAC_SCREENSHOT] },
321
+ { name: "a Windows drive path with unescaped spaces", text: WINDOWS_SPACED, expected: [WINDOWS_SPACED] },
322
+ {
323
+ name: "a home-anchored path with unescaped spaces",
324
+ text: "~/Pictures/Cleanshot 2026-07-24 at 12.00.png",
325
+ expected: ["~/Pictures/Cleanshot 2026-07-24 at 12.00.png"],
326
+ },
327
+ {
328
+ name: "a shell-escaped spaced path",
329
+ text: "/tmp/My\\ Photos/shot\\ 1.png",
330
+ expected: ["/tmp/My Photos/shot 1.png"],
331
+ },
332
+ {
333
+ name: "a double-quoted spaced path",
334
+ text: '"/tmp/My Photos/shot 1.png"',
335
+ expected: ["/tmp/My Photos/shot 1.png"],
336
+ },
337
+ { name: "a spaced path with a non-image extension", text: "/tmp/my report 2026.csv", expected: undefined },
338
+ {
339
+ // Ends in a real image extension, so only the absolute-prefix
340
+ // anchor keeps the whole-text fallback from swallowing the prose.
341
+ name: "prose ending in a path-shaped fragment",
342
+ text: "see /Users/me/Desktop/Screen Shot 1.png",
343
+ expected: undefined,
344
+ },
345
+ {
346
+ name: "two spaced image paths on separate lines",
347
+ text: "/tmp/a shot.png\n/tmp/b shot.png",
348
+ expected: undefined,
349
+ },
350
+ {
351
+ name: "a bare spaced filename with no leading separator",
352
+ text: "Screenshot 2026-07-24 at 1.55.12 PM.png",
353
+ expected: undefined,
354
+ },
355
+ {
356
+ name: "two POSIX paths dragged together when one has unescaped spaces",
357
+ text: "/tmp/a.png /tmp/b shot.png",
358
+ expected: undefined,
359
+ },
360
+ {
361
+ name: "two macOS screenshots dragged together",
362
+ text: `${MAC_SCREENSHOT} /var/folders/xx/T/TemporaryItems/NSIRD_screencaptureui_ab/Screenshot 2026-07-24 at 1.56.00 PM.png`,
363
+ expected: undefined,
364
+ },
365
+ {
366
+ name: "two home-anchored paths with unescaped spaces",
367
+ text: "~/a.png ~/Pictures/b shot.png",
368
+ expected: undefined,
369
+ },
370
+ {
371
+ name: "two Windows drive paths with unescaped spaces",
372
+ text: `C:\\Users\\me\\a.png ${WINDOWS_SPACED}`,
373
+ expected: undefined,
374
+ },
375
+ {
376
+ name: "two `file://` URLs with unescaped spaces",
377
+ text: "file:///tmp/a.png file:///tmp/b shot.png",
378
+ expected: undefined,
379
+ },
380
+ {
381
+ name: "two UNC paths with unescaped spaces",
382
+ text: "\\\\srv\\share\\a.png \\\\srv\\share\\b shot.png",
383
+ expected: undefined,
384
+ },
385
+ {
386
+ name: "a tab-separated pair of dragged paths",
387
+ text: "/tmp/a.png\t/tmp/b shot.png",
388
+ expected: undefined,
389
+ },
390
+ {
391
+ name: "an absolute path followed by a dot-relative path with unescaped spaces",
392
+ text: "/tmp/a.png ./b shot.png",
393
+ expected: undefined,
394
+ },
395
+ {
396
+ name: "an absolute path followed by a parent-relative path with unescaped spaces",
397
+ text: "/tmp/a.png ../pics/b shot.png",
398
+ expected: undefined,
399
+ },
400
+ {
401
+ name: "a Windows drive path followed by a dot-relative path with unescaped spaces",
402
+ text: "C:\\Users\\me\\a.png .\\b shot.png",
403
+ expected: undefined,
404
+ },
405
+ {
406
+ // The interior `Photos/shot` token after an unescaped space is the
407
+ // shape of a spaced directory name, not of a second dragged path —
408
+ // this is why bare relatives are not multi-path anchors.
409
+ name: "a single path with an unescaped spaced directory name",
410
+ text: "/Users/me/My Photos/shot 1.png",
411
+ expected: ["/Users/me/My Photos/shot 1.png"],
412
+ },
413
+ {
414
+ // The escape asserts the space belongs to the path, so the `/sub`
415
+ // that follows is a component rather than a second drag payload.
416
+ name: "a path whose escaped space precedes a slash-led component",
417
+ text: "/tmp/odd dir\\ /sub/a b.png",
418
+ expected: ["/tmp/odd dir /sub/a b.png"],
419
+ },
420
+ {
421
+ // Splitter-success path: both segments are explicit, so the
422
+ // whole-text pass never runs and the pair still attaches as two.
423
+ name: "two explicit image paths the splitter can separate",
424
+ text: "/tmp/a.png /tmp/b.png",
425
+ expected: ["/tmp/a.png", "/tmp/b.png"],
426
+ },
427
+ ];
428
+
429
+ for (const { name, text, expected } of cases) {
430
+ it(`${expected ? "recovers" : "rejects"} ${name} on both paste routes`, () => {
431
+ expect(extractImagePastePathsFromText(text)).toEqual(expected);
432
+ expect(extractBracketedImagePastePaths(bracketedPaste(text))).toEqual(expected);
433
+ });
434
+ }
435
+ });
436
+
267
437
  describe("CustomEditor space-hold push-to-talk", () => {
268
438
  beforeAll(async () => {
269
439
  await initTheme();