@oh-my-pi/pi-coding-agent 15.9.3 → 15.9.5

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 (63) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/config/settings-schema.d.ts +13 -4
  4. package/dist/types/modes/components/assistant-message.d.ts +11 -0
  5. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  6. package/dist/types/modes/components/error-banner.d.ts +11 -0
  7. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  8. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  9. package/dist/types/modes/components/user-message.d.ts +1 -1
  10. package/dist/types/modes/image-references.d.ts +17 -0
  11. package/dist/types/modes/interactive-mode.d.ts +7 -0
  12. package/dist/types/modes/types.d.ts +7 -0
  13. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  14. package/dist/types/session/blob-store.d.ts +12 -11
  15. package/dist/types/session/session-manager.d.ts +5 -3
  16. package/dist/types/system-prompt.d.ts +2 -0
  17. package/dist/types/tiny/title-client.d.ts +16 -1
  18. package/dist/types/tool-discovery/mode.d.ts +8 -0
  19. package/dist/types/tools/archive-reader.d.ts +5 -1
  20. package/dist/types/tui/hyperlink.d.ts +12 -0
  21. package/dist/types/web/search/render.d.ts +1 -2
  22. package/package.json +9 -9
  23. package/src/cli/classify-install-target.ts +31 -5
  24. package/src/cli/plugin-cli.ts +45 -0
  25. package/src/cli/web-search-cli.ts +0 -1
  26. package/src/config/model-registry.ts +54 -4
  27. package/src/config/settings-schema.ts +14 -4
  28. package/src/eval/__tests__/agent-bridge.test.ts +72 -0
  29. package/src/eval/py/tool-bridge.ts +43 -5
  30. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  31. package/src/internal-urls/docs-index.generated.ts +3 -3
  32. package/src/main.ts +7 -1
  33. package/src/modes/components/assistant-message.ts +22 -0
  34. package/src/modes/components/custom-editor.ts +14 -2
  35. package/src/modes/components/error-banner.ts +33 -0
  36. package/src/modes/components/tool-execution.ts +44 -0
  37. package/src/modes/components/transcript-container.ts +93 -32
  38. package/src/modes/components/user-message.ts +9 -2
  39. package/src/modes/controllers/event-controller.ts +42 -3
  40. package/src/modes/controllers/input-controller.ts +33 -1
  41. package/src/modes/image-references.ts +111 -0
  42. package/src/modes/interactive-mode.ts +48 -13
  43. package/src/modes/types.ts +10 -1
  44. package/src/modes/utils/ui-helpers.ts +23 -2
  45. package/src/prompts/ci-green-request.md +5 -3
  46. package/src/prompts/system/project-prompt.md +1 -0
  47. package/src/sdk.ts +17 -9
  48. package/src/session/agent-session.ts +37 -12
  49. package/src/session/blob-store.ts +96 -9
  50. package/src/session/session-manager.ts +19 -10
  51. package/src/system-prompt.ts +4 -0
  52. package/src/tiny/title-client.ts +7 -1
  53. package/src/tool-discovery/mode.ts +24 -0
  54. package/src/tools/archive-reader.ts +339 -31
  55. package/src/tools/fetch.ts +29 -9
  56. package/src/tools/gh.ts +65 -11
  57. package/src/tools/index.ts +6 -8
  58. package/src/tools/read.ts +58 -12
  59. package/src/tools/search-tool-bm25.ts +4 -6
  60. package/src/tools/search.ts +60 -11
  61. package/src/tui/hyperlink.ts +42 -7
  62. package/src/web/search/index.ts +2 -2
  63. package/src/web/search/render.ts +20 -52
package/src/main.ts CHANGED
@@ -285,7 +285,13 @@ async function runInteractiveMode(
285
285
  })
286
286
  .catch(() => {});
287
287
 
288
- mode.renderInitialMessages(undefined, { preserveExistingChat: true });
288
+ // Cold-launch cleanup: wipe the terminal scrollback before painting the
289
+ // resumed/new transcript. The TUI's initial paint deliberately preserves
290
+ // native scrollback (prior shell content), but on `omp`/`omp -c` that leaves
291
+ // the previous run's welcome + transcript stacked above the fresh one. Every
292
+ // in-process session load already clears via `clearTerminalHistory`; the cold
293
+ // launch is the lone path that did not.
294
+ mode.renderInitialMessages(undefined, { preserveExistingChat: true, clearTerminalHistory: true });
289
295
 
290
296
  for (const notify of notifs) {
291
297
  if (!notify) {
@@ -17,6 +17,7 @@ export class AssistantMessageComponent extends Container {
17
17
  #usageInfo?: Usage;
18
18
  #convertedKittyImages = new Map<string, ImageContent>();
19
19
  #kittyConversionsInFlight = new Set<string>();
20
+ #transcriptBlockFinalized: boolean;
20
21
 
21
22
  constructor(
22
23
  message?: AssistantMessage,
@@ -26,6 +27,7 @@ export class AssistantMessageComponent extends Container {
26
27
  private readonly imageBudget?: ImageBudget,
27
28
  ) {
28
29
  super();
30
+ this.#transcriptBlockFinalized = message !== undefined;
29
31
 
30
32
  // Container for text/thinking content
31
33
  this.#contentContainer = new Container();
@@ -47,6 +49,26 @@ export class AssistantMessageComponent extends Container {
47
49
  this.hideThinkingBlock = hide;
48
50
  }
49
51
 
52
+ isTranscriptBlockFinalized(): boolean {
53
+ return this.#transcriptBlockFinalized;
54
+ }
55
+
56
+ /**
57
+ * Assistant text/thinking streams in append-only: earlier rendered rows never
58
+ * re-layout, new content only grows the block at the bottom. The transcript
59
+ * reports this so the renderer may commit scrolled-off head rows of a long
60
+ * streamed reply to native scrollback instead of dropping them (see
61
+ * `NativeScrollbackLiveRegion#getNativeScrollbackCommitSafeEnd`). Volatile
62
+ * blocks (tool previews that collapse) intentionally do not implement this.
63
+ */
64
+ isTranscriptBlockAppendOnly(): boolean {
65
+ return true;
66
+ }
67
+
68
+ markTranscriptBlockFinalized(): void {
69
+ this.#transcriptBlockFinalized = true;
70
+ }
71
+
50
72
  setToolResultImages(toolCallId: string, images: ImageContent[]): void {
51
73
  if (!toolCallId) return;
52
74
  const validImages = images.filter(img => img.type === "image" && img.data && img.mimeType);
@@ -1,6 +1,8 @@
1
1
  import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
2
2
  import type { AppKeybinding } from "../../config/keybindings";
3
+ import { imageReferenceHyperlink, renderImageReferences } from "../image-references";
3
4
  import { highlightMagicKeywords } from "../magic-keywords";
5
+ import { theme } from "../theme/theme";
4
6
 
5
7
  type ConfigurableEditorAction = Extract<
6
8
  AppKeybinding,
@@ -47,9 +49,19 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
47
49
  * Custom editor that handles configurable app-level shortcuts for coding-agent.
48
50
  */
49
51
  export class CustomEditor extends Editor {
52
+ imageLinks?: readonly (string | undefined)[];
53
+
50
54
  /** Gradient-highlight the "ultrathink" / "orchestrate" / "workflow" keywords as the user types
51
- * them, skipping any occurrence inside code spans, fenced blocks, or XML sections. */
52
- decorateText = (text: string): string => highlightMagicKeywords(text);
55
+ * them, skipping any occurrence inside code spans, fenced blocks, or XML sections. Also make
56
+ * pasted image placeholders visually distinct and hyperlink them once their blob file exists. */
57
+ decorateText = (text: string): string =>
58
+ renderImageReferences(text, {
59
+ renderText: value => highlightMagicKeywords(value),
60
+ renderReference: (value, index) =>
61
+ imageReferenceHyperlink(value, index, this.imageLinks, label =>
62
+ theme.fg("accent", `\x1b[1m\x1b[4m${label}\x1b[24m\x1b[22m`),
63
+ ),
64
+ });
53
65
  onEscape?: () => void;
54
66
  onClear?: () => void;
55
67
  onExit?: () => void;
@@ -0,0 +1,33 @@
1
+ import { Container, Spacer, Text } from "@oh-my-pi/pi-tui";
2
+ import { getPreviewLines, TRUNCATE_LENGTHS } from "../../tools/render-utils";
3
+ import { theme } from "../theme/theme";
4
+ import { DynamicBorder } from "./dynamic-border";
5
+
6
+ /** Max lines of the error message shown in the pinned banner. */
7
+ const MAX_BANNER_LINES = 3;
8
+
9
+ /**
10
+ * A persistent error banner pinned above the editor. Unlike the transcript
11
+ * "Error: …" line (which scrolls away as the conversation grows), this stays in
12
+ * the fixed region directly above the input so a turn that ended on a provider
13
+ * error — e.g. Anthropic's "Output blocked by content filtering policy" — cannot
14
+ * be missed. It is cleared when the next turn starts.
15
+ */
16
+ export class ErrorBannerComponent extends Container {
17
+ constructor(message: string) {
18
+ super();
19
+ const lines = getPreviewLines(message, MAX_BANNER_LINES, TRUNCATE_LENGTHS.LINE);
20
+ if (lines.length === 0) {
21
+ lines.push("Unknown error");
22
+ }
23
+
24
+ this.addChild(new Spacer(1));
25
+ this.addChild(new DynamicBorder(str => theme.fg("error", str)));
26
+ this.addChild(new Text(theme.bold(theme.fg("error", `${theme.status.error} ${lines[0]}`)), 1, 0));
27
+ for (const line of lines.slice(1)) {
28
+ this.addChild(new Text(theme.fg("error", ` ${line}`), 1, 0));
29
+ }
30
+ this.addChild(new Text(theme.fg("dim", "Dismissed when you send your next message."), 1, 0));
31
+ this.addChild(new DynamicBorder(str => theme.fg("error", str)));
32
+ }
33
+ }
@@ -197,6 +197,11 @@ export class ToolExecutionComponent extends Container {
197
197
  #todoStrikeInterval?: NodeJS.Timeout;
198
198
  // Track if args are still being streamed (for edit/write spinner)
199
199
  #argsComplete = false;
200
+ // Sealed once the tool reaches a terminal state (result delivered, or the
201
+ // turn abandoned it without one). Drives `isTranscriptBlockFinalized`: until
202
+ // sealed the block stays in the transcript's repaintable live region so a
203
+ // late result still repaints instead of stranding the streaming preview.
204
+ #sealed = false;
200
205
  #renderState: {
201
206
  spinnerFrame?: number;
202
207
  expanded: boolean;
@@ -448,6 +453,13 @@ export class ToolExecutionComponent extends Container {
448
453
  } else if (!needsSpinner && this.#spinnerInterval) {
449
454
  clearInterval(this.#spinnerInterval);
450
455
  this.#spinnerInterval = undefined;
456
+ // Clear the last drawn frame so a non-live renderCall (e.g. a write whose
457
+ // args just completed) stops showing a frozen spinner glyph. Skip when a
458
+ // todo strike owns the frame — it sets its own value right after this.
459
+ if (!this.#todoStrikeInterval) {
460
+ this.#spinnerFrame = undefined;
461
+ this.#renderState.spinnerFrame = undefined;
462
+ }
451
463
  }
452
464
  }
453
465
 
@@ -488,6 +500,37 @@ export class ToolExecutionComponent extends Container {
488
500
  }
489
501
  }
490
502
 
503
+ /**
504
+ * Whether this block has reached a terminal state for transcript freezing.
505
+ * Reports `false` while it can still visually change so the
506
+ * {@link TranscriptContainer} keeps it inside the repaintable live region:
507
+ * a foreground tool awaiting its result, or one streaming partial output.
508
+ * A final (non-partial) result, a background-async tool the agent has moved
509
+ * past, or an explicit {@link seal} flips it to `true`.
510
+ */
511
+ isTranscriptBlockFinalized(): boolean {
512
+ if (this.#sealed) return true;
513
+ if (this.#result === undefined) return false;
514
+ if (!this.#isPartial) return true;
515
+ // Partial result: a background async tool is accepted to freeze (the agent
516
+ // continues while it runs and would otherwise pin an unbounded live region);
517
+ // a foreground tool streaming partial output stays live until it finishes.
518
+ return (this.#result.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
519
+ }
520
+
521
+ /**
522
+ * Mark the tool terminal even though no result arrived (the turn aborted or
523
+ * abandoned it) and stop animating, so it can freeze and stops pinning the
524
+ * transcript live region.
525
+ */
526
+ seal(): void {
527
+ if (this.#sealed) return;
528
+ this.#sealed = true;
529
+ this.stopAnimation();
530
+ this.#updateDisplay();
531
+ this.#ui.requestRender();
532
+ }
533
+
491
534
  /**
492
535
  * Stop spinner animation and cleanup resources.
493
536
  */
@@ -496,6 +539,7 @@ export class ToolExecutionComponent extends Container {
496
539
  clearInterval(this.#spinnerInterval);
497
540
  this.#spinnerInterval = undefined;
498
541
  this.#spinnerFrame = undefined;
542
+ this.#renderState.spinnerFrame = undefined;
499
543
  }
500
544
  this.#stopTodoStrikeAnimation();
501
545
  this.#editDiffAbort?.abort();
@@ -12,6 +12,33 @@ interface SnapshotCarrier {
12
12
  [kSnapshot]?: FrozenRender;
13
13
  }
14
14
 
15
+ /**
16
+ * A transcript block that is still mutating (a foreground tool awaiting its
17
+ * result, an assistant message mid-stream) reports `false` so the container
18
+ * keeps it inside the live (repaintable) region instead of freezing it. Blocks
19
+ * without the method are treated as finalized — the default, stable behavior.
20
+ *
21
+ * `isTranscriptBlockAppendOnly` marks a still-live block whose rendered rows
22
+ * only grow at the bottom and never re-layout (a streaming assistant reply).
23
+ * Such a block's scrolled-off head is safe to commit to native scrollback even
24
+ * while live; blocks that omit it (tool previews that collapse to a compact
25
+ * result) keep their mutable rows deferred. Default is `false`.
26
+ */
27
+ interface FinalizableBlock {
28
+ isTranscriptBlockFinalized?(): boolean;
29
+ isTranscriptBlockAppendOnly?(): boolean;
30
+ }
31
+
32
+ function isBlockFinalized(child: Component): boolean {
33
+ const fn = (child as Component & FinalizableBlock).isTranscriptBlockFinalized;
34
+ return fn ? fn.call(child) : true;
35
+ }
36
+
37
+ function isBlockAppendOnly(child: Component): boolean {
38
+ const fn = (child as Component & FinalizableBlock).isTranscriptBlockAppendOnly;
39
+ return fn ? fn.call(child) : false;
40
+ }
41
+
15
42
  /**
16
43
  * Transcript container that freezes the rendered output of every block except
17
44
  * the bottom-most (live) one on terminals where committed native scrollback is
@@ -38,15 +65,24 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
38
65
  // Bumped to invalidate every block's snapshot at once; a snapshot is only
39
66
  // honored when its stored generation still matches.
40
67
  #generation = 0;
41
- // The block that was bottom-most (live) on the previous render. When the live
42
- // position moves past it, its snapshot was last refreshed mid-stream and may
43
- // predate content that finalized in the same coalesced frame that appended the
44
- // block now below it so it must recompute once on the live→frozen transition.
45
- #prevLiveChild: Component | undefined;
46
- // Local line index where the current bottom-most block begins in the most
47
- // recent render. TUI extends the native-scrollback pinned region from this
48
- // point through the live block and the root chrome rendered below it.
68
+ // Line index where the live (repaintable) region began on the previous
69
+ // render the start of the earliest still-mutating block, or the bottom
70
+ // block when everything is finalized. A block leaves the live region only
71
+ // once it has finalized AND a finalized block sits below it; the frame it
72
+ // crosses out is recomputed so it freezes at its true final content, not the
73
+ // mid-stream snapshot it last rendered while live (TUI render coalescing can
74
+ // advance a block's content in the very frame it stops being live).
75
+ #prevLiveStartIndex = 0;
76
+ // Local line index where the current live region begins in the most recent
77
+ // render. TUI extends the native-scrollback pinned region from this point
78
+ // through the live blocks and the root chrome rendered below them.
49
79
  #nativeScrollbackLiveRegionStart: number | undefined;
80
+ // Local line index up to which the leading run of live blocks is append-only
81
+ // (a streaming assistant reply): everything in [liveRegionStart,
82
+ // commitSafeEnd) only grows at the bottom and never re-layouts, so its
83
+ // scrolled-off head is safe to commit to native scrollback. `undefined` when
84
+ // the first live block is volatile (a tool preview).
85
+ #nativeScrollbackCommitSafeEnd: number | undefined;
50
86
 
51
87
  override invalidate(): void {
52
88
  // A theme/global invalidation forces a full recompute on the rebuild that
@@ -64,6 +100,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
64
100
  return this.#nativeScrollbackLiveRegionStart;
65
101
  }
66
102
 
103
+ getNativeScrollbackCommitSafeEnd(): number | undefined {
104
+ return this.#nativeScrollbackCommitSafeEnd;
105
+ }
106
+
67
107
  /**
68
108
  * Retire all frozen snapshots so the next render reflects each block's current
69
109
  * state. Call at reconciliation checkpoints (prompt submit) where the whole
@@ -77,41 +117,62 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
77
117
  override render(width: number): string[] {
78
118
  width = Math.max(1, width);
79
119
  this.#nativeScrollbackLiveRegionStart = undefined;
120
+ this.#nativeScrollbackCommitSafeEnd = undefined;
80
121
  if (!TERMINAL.eagerEraseScrollbackRisk) return super.render(width);
81
122
 
123
+ const count = this.children.length;
124
+ // The live region spans from the earliest still-mutating block through the
125
+ // bottom. A block that has not finalized must stay repaintable: out-of-band
126
+ // inserts (TTSR/todo cards) can append a finalized block *below* a tool that
127
+ // is still awaiting its result, and freezing the tool there would strand its
128
+ // committed rows on the mid-stream preview the late result never reaches.
129
+ let liveStartIndex = count - 1;
130
+ for (let i = 0; i < count; i++) {
131
+ if (!isBlockFinalized(this.children[i]!)) {
132
+ liveStartIndex = i;
133
+ break;
134
+ }
135
+ }
136
+ // Blocks at [prevLiveStart, liveStart) just crossed out of the live region;
137
+ // recompute them so they freeze at their final content. Everything below
138
+ // the lower of the two cutoffs was already frozen last frame and replays.
139
+ const replayCutoff = Math.min(liveStartIndex, this.#prevLiveStartIndex);
140
+ this.#prevLiveStartIndex = liveStartIndex;
141
+
82
142
  const lines: string[] = [];
83
- const liveIndex = this.children.length - 1;
84
- const liveChild = this.children[liveIndex];
85
- const prevLiveChild = this.#prevLiveChild;
86
- this.#prevLiveChild = liveChild;
87
- for (let i = 0; i < this.children.length; i++) {
143
+ // Tracks whether we are still inside the leading run of append-only live
144
+ // blocks. The first non-append-only live block (or a finalized block below
145
+ // the live region's start, which cannot happen for a leading run) closes it.
146
+ let commitSafeOpen = true;
147
+ for (let i = 0; i < count; i++) {
88
148
  const child = this.children[i]! as Component & SnapshotCarrier;
89
- if (child === liveChild) {
90
- this.#nativeScrollbackLiveRegionStart = lines.length;
149
+ if (i >= liveStartIndex) {
150
+ if (i === liveStartIndex) this.#nativeScrollbackLiveRegionStart = lines.length;
91
151
  } else {
92
152
  const snapshot = child[kSnapshot];
93
- // Replay the block's last render from while it was live. A stale
94
- // generation (post-thaw) or width mismatch (resize in flight, an
95
- // explicit rebuild that reconciles history anyway) recomputes instead.
96
- // The block that was live on the previous render is also recomputed
97
- // here: TUI render coalescing can advance its content (final streamed
98
- // tokens) in the very frame that appends the block now below it, so its
99
- // cached snapshot predates that final content. Recomputing on the
100
- // transition seals the block at its true final state, not a mid-stream one.
101
- if (
102
- child !== prevLiveChild &&
103
- snapshot &&
104
- snapshot.generation === this.#generation &&
105
- snapshot.width === width
106
- ) {
153
+ // Replay a frozen block's last live render. A stale generation
154
+ // (post-thaw) or width mismatch (resize, explicit rebuild) recomputes
155
+ // instead, as does a block that was still live last frame (i >= cutoff).
156
+ if (i < replayCutoff && snapshot && snapshot.generation === this.#generation && snapshot.width === width) {
107
157
  lines.push(...snapshot.lines);
108
158
  continue;
109
159
  }
110
160
  }
111
161
  const rendered = child.render(width);
112
- // Cache every block's latest render. While a block is live this keeps its
113
- // snapshot current; on the frame it stops being live the recompute above
114
- // refreshes it to the final state before it freezes.
162
+ // Extend the commit-safe boundary through each leading append-only live
163
+ // block. `lines.length` here is this block's start offset; the boundary
164
+ // runs to the end of its rendered rows. The first volatile live block
165
+ // closes the run so its mutable rows stay deferred.
166
+ if (i >= liveStartIndex && commitSafeOpen) {
167
+ if (isBlockAppendOnly(child)) {
168
+ this.#nativeScrollbackCommitSafeEnd = lines.length + rendered.length;
169
+ } else {
170
+ commitSafeOpen = false;
171
+ }
172
+ }
173
+ // Cache every block's latest render. While a block is in the live region
174
+ // this keeps its snapshot current; on the frame it crosses out, the
175
+ // recompute above refreshes it to the final state before it freezes.
115
176
  child[kSnapshot] = { width, lines: rendered, generation: this.#generation };
116
177
  lines.push(...rendered);
117
178
  }
@@ -1,5 +1,6 @@
1
1
  import { Container, Markdown, Spacer } from "@oh-my-pi/pi-tui";
2
2
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
3
+ import { imageReferenceHyperlink, renderImageReferences } from "../image-references";
3
4
  import { highlightMagicKeywords } from "../magic-keywords";
4
5
 
5
6
  // OSC 133 shell integration: marks prompt zones for terminal multiplexers
@@ -11,7 +12,7 @@ const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
11
12
  * Component that renders a user message
12
13
  */
13
14
  export class UserMessageComponent extends Container {
14
- constructor(text: string, synthetic = false) {
15
+ constructor(text: string, synthetic = false, imageLinks?: readonly (string | undefined)[]) {
15
16
  super();
16
17
  const bgColor = (value: string) => theme.bg("userMessageBg", value);
17
18
  // Paint the magic keywords ("ultrathink"/"orchestrate"/"workflow") inside the rendered
@@ -20,9 +21,15 @@ export class UserMessageComponent extends Container {
20
21
  // `highlightMagicKeywords` additionally restores the bubble's own foreground after each
21
22
  // painted keyword so the gradient never bleeds into the rest of the line.
22
23
  const keywordReset = theme.getFgAnsi("userMessageText") || "\x1b[39m";
23
- const color = synthetic
24
+ const baseText = synthetic
24
25
  ? (value: string) => theme.fg("dim", value)
25
26
  : (value: string) => theme.fg("userMessageText", highlightMagicKeywords(value, keywordReset));
27
+ const imageLabel = (value: string) => theme.fg("accent", `\x1b[1m\x1b[4m${value}\x1b[24m\x1b[22m`);
28
+ const color = (value: string) =>
29
+ renderImageReferences(value, {
30
+ renderText: baseText,
31
+ renderReference: (label, index) => imageReferenceHyperlink(label, index, imageLinks, imageLabel),
32
+ });
26
33
  this.addChild(new Spacer(1));
27
34
  this.addChild(
28
35
  new Markdown(text, 1, 1, getMarkdownTheme(), {
@@ -204,6 +204,7 @@ export class EventController {
204
204
  this.#readToolCallAssistantComponents.clear();
205
205
  this.#assistantMessageStreaming = false;
206
206
  this.#lastAssistantComponent = undefined;
207
+ this.ctx.clearPinnedError();
207
208
  if (this.ctx.retryEscapeHandler) {
208
209
  this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
209
210
  this.ctx.retryEscapeHandler = undefined;
@@ -241,16 +242,28 @@ export class EventController {
241
242
  this.ctx.ui.requestRender();
242
243
  } else if (event.message.role === "user") {
243
244
  const textContent = this.ctx.getUserMessageText(event.message);
244
- const imageCount =
245
+ const imageBlocks =
245
246
  typeof event.message.content === "string"
246
- ? 0
247
- : event.message.content.filter(content => content.type === "image").length;
247
+ ? []
248
+ : event.message.content.filter(
249
+ (content): content is ImageContent =>
250
+ content.type === "image" &&
251
+ typeof content.data === "string" &&
252
+ typeof content.mimeType === "string",
253
+ );
254
+ const imageCount = imageBlocks.length;
248
255
  const signature = `${textContent}\u0000${imageCount}`;
249
256
 
250
257
  this.#resetReadGroup();
251
258
  const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
252
259
  const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
253
260
  if (!wasOptimistic) {
261
+ // Append synchronously: #emit dispatches to this listener fire-and-forget
262
+ // (see AgentSession.#emit), so any await between the user message_start and
263
+ // addMessageToChat lets later events (assistant message_start, tool execution
264
+ // start/end) append their components first and scramble transcript order /
265
+ // live-region block boundaries. addMessageToChat materializes clickable image
266
+ // links via the synchronous putBlobSync fallback, so no await is needed here.
254
267
  this.ctx.addMessageToChat(event.message);
255
268
  }
256
269
  if (wasOptimistic) {
@@ -462,11 +475,32 @@ export class EventController {
462
475
  for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
463
476
  component.setArgsComplete(toolCallId);
464
477
  }
478
+ } else {
479
+ // The turn ended without running these calls (abort/error/TTSR rewind),
480
+ // so they will never produce a result. Seal them so they stop animating
481
+ // and freeze instead of pinning the transcript live region while a retry
482
+ // streams fresh blocks below them. Background tools keep updating.
483
+ for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
484
+ if (!this.#backgroundToolCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) {
485
+ component.seal();
486
+ }
487
+ }
465
488
  }
466
489
  this.#lastAssistantComponent = this.ctx.streamingComponent;
467
490
  this.#lastAssistantComponent.setUsageInfo(event.message.usage);
491
+ this.#lastAssistantComponent.markTranscriptBlockFinalized();
468
492
  this.ctx.streamingComponent = undefined;
469
493
  this.ctx.streamingMessage = undefined;
494
+ // Pin a turn-ending provider error (e.g. Anthropic content-filter block)
495
+ // above the editor so it survives transcript scroll. Cleared at the next
496
+ // turn's agent_start.
497
+ if (
498
+ event.message.stopReason === "error" &&
499
+ event.message.errorMessage &&
500
+ !isSilentAbort(event.message.errorMessage)
501
+ ) {
502
+ this.ctx.showPinnedError(event.message.errorMessage);
503
+ }
470
504
  this.ctx.statusLine.invalidate();
471
505
  this.ctx.updateEditorTopBorder();
472
506
  }
@@ -626,6 +660,11 @@ export class EventController {
626
660
  await this.ctx.flushPendingModelSwitch();
627
661
  for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) {
628
662
  if (!this.#backgroundToolCallIds.has(toolCallId)) {
663
+ // A foreground tool still pending at turn end never delivered a result;
664
+ // seal it so it freezes (and stops animating) rather than lingering in
665
+ // the transcript live region as a streaming preview until the next thaw.
666
+ const component = this.ctx.pendingTools.get(toolCallId);
667
+ if (component instanceof ToolExecutionComponent) component.seal();
629
668
  this.ctx.pendingTools.delete(toolCallId);
630
669
  }
631
670
  }
@@ -6,6 +6,7 @@ import { isSettingsInitialized, settings } from "../../config/settings";
6
6
  import { renderSegmentTrack } from "../../modes/components/segment-track";
7
7
  import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
8
8
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
9
+ import { materializeImageReferenceLinks } from "../../modes/image-references";
9
10
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
10
11
  import type { InteractiveModeContext } from "../../modes/types";
11
12
  import type { AgentSessionEvent } from "../../session/agent-session";
@@ -253,6 +254,8 @@ export class InputController {
253
254
  if (this.ctx.onInputCallback) {
254
255
  this.ctx.editor.setText("");
255
256
  this.ctx.pendingImages = [];
257
+ this.ctx.pendingImageLinks = [];
258
+ this.ctx.editor.imageLinks = undefined;
256
259
  this.ctx.onInputCallback({ text: "", cancelled: false, started: true });
257
260
  }
258
261
  return;
@@ -260,12 +263,15 @@ export class InputController {
260
263
 
261
264
  const runner = this.ctx.session.extensionRunner;
262
265
  let inputImages = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
266
+ let inputImageLinks = this.ctx.pendingImageLinks.length > 0 ? [...this.ctx.pendingImageLinks] : undefined;
263
267
 
264
268
  if (runner?.hasHandlers("input")) {
265
269
  const result = await runner.emitInput(text, inputImages, "interactive");
266
270
  if (result?.handled) {
267
271
  this.ctx.editor.setText("");
268
272
  this.ctx.pendingImages = [];
273
+ this.ctx.pendingImageLinks = [];
274
+ this.ctx.editor.imageLinks = undefined;
269
275
  return;
270
276
  }
271
277
  if (result?.text !== undefined) {
@@ -273,6 +279,10 @@ export class InputController {
273
279
  }
274
280
  if (result?.images !== undefined) {
275
281
  inputImages = result.images;
282
+ inputImageLinks = await materializeImageReferenceLinks(
283
+ inputImages,
284
+ this.ctx.sessionManager.putBlob.bind(this.ctx.sessionManager),
285
+ );
276
286
  }
277
287
  }
278
288
 
@@ -356,8 +366,10 @@ export class InputController {
356
366
  if (this.ctx.session.isStreaming) {
357
367
  this.ctx.editor.addToHistory(text);
358
368
  this.ctx.editor.setText("");
369
+ this.ctx.editor.imageLinks = undefined;
359
370
  const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
360
371
  this.ctx.pendingImages = [];
372
+ this.ctx.pendingImageLinks = [];
361
373
  // Record the signature so the queued message's eventual delivery
362
374
  // (a user-role `message_start` event) leaves any draft the user has
363
375
  // typed since queuing intact. Same protection as #783, applied to
@@ -417,11 +429,17 @@ export class InputController {
417
429
 
418
430
  if (this.ctx.onInputCallback) {
419
431
  // Include any pending images from clipboard paste
432
+ this.ctx.editor.imageLinks = undefined;
420
433
  const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
421
434
  this.ctx.pendingImages = [];
435
+ this.ctx.pendingImageLinks = [];
422
436
 
423
437
  // Render user message immediately, then let session events catch up
424
- const submission = this.ctx.startPendingSubmission({ text, images });
438
+ const submission = this.ctx.startPendingSubmission({
439
+ text,
440
+ images,
441
+ imageLinks: inputImageLinks,
442
+ });
425
443
 
426
444
  this.ctx.onInputCallback(submission);
427
445
  }
@@ -685,11 +703,25 @@ export class InputController {
685
703
  }
686
704
  }
687
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];
688
718
  this.ctx.pendingImages.push({
689
719
  type: "image",
690
720
  data: imageData.data,
691
721
  mimeType: imageData.mimeType,
692
722
  });
723
+ this.ctx.pendingImageLinks.push(imageLink);
724
+ this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
693
725
  // Insert placeholder at cursor like Claude does
694
726
  const imageNum = this.ctx.pendingImages.length;
695
727
  const placeholder = `[Image #${imageNum}]`;