@oh-my-pi/pi-tui 18.4.0 → 18.4.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 (65) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/types/chat/image-loading.d.ts +24 -0
  3. package/dist/types/chat/transcript-entry.d.ts +9 -0
  4. package/dist/types/chrome/transcript-container.d.ts +24 -3
  5. package/dist/types/overlays/agents-hub.d.ts +7 -4
  6. package/dist/types/overlays/model-browser.d.ts +15 -0
  7. package/dist/types/overlays/rewind-selector.d.ts +4 -3
  8. package/dist/types/overlays/usage-dashboard.d.ts +9 -2
  9. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  10. package/dist/types/prompt/composer-cache.d.ts +34 -16
  11. package/dist/types/prompt/composer.d.ts +15 -18
  12. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  13. package/dist/types/prompt/welcome.d.ts +5 -2
  14. package/dist/types/render/width-aware-text.d.ts +6 -0
  15. package/dist/types/status-line/component.d.ts +13 -4
  16. package/dist/types/status-line/metrics.d.ts +0 -1
  17. package/dist/types/status-line/startup.d.ts +38 -0
  18. package/dist/types/status-line/types.d.ts +0 -2
  19. package/dist/types/terminal-capabilities.d.ts +10 -6
  20. package/dist/types/terminal.d.ts +7 -0
  21. package/dist/types/theme/color.d.ts +1 -0
  22. package/dist/types/theme/session-color.d.ts +3 -3
  23. package/dist/types/theme/shimmer.d.ts +1 -1
  24. package/dist/types/theme/theme-class.d.ts +1 -1
  25. package/dist/types/tools/output-meta.d.ts +2 -1
  26. package/dist/types/tools/vibe.d.ts +4 -0
  27. package/dist/types/tui.d.ts +3 -2
  28. package/package.json +9 -9
  29. package/src/chat/assistant-message.ts +27 -21
  30. package/src/chat/image-loading.ts +81 -0
  31. package/src/chat/tool-execution.ts +53 -17
  32. package/src/chat/transcript-entry.ts +16 -0
  33. package/src/chrome/transcript-container.ts +282 -86
  34. package/src/components/editor.ts +28 -6
  35. package/src/components/image.ts +25 -9
  36. package/src/components/loader.ts +37 -17
  37. package/src/overlays/agent-hub.ts +16 -6
  38. package/src/overlays/agents-hub.ts +7 -18
  39. package/src/overlays/copy-selector.ts +8 -31
  40. package/src/overlays/model-browser.ts +106 -25
  41. package/src/overlays/model-hub.ts +49 -16
  42. package/src/overlays/rewind-selector.ts +60 -13
  43. package/src/overlays/usage-dashboard.ts +47 -6
  44. package/src/prompt/composer-attachments.ts +54 -7
  45. package/src/prompt/composer-cache.ts +217 -196
  46. package/src/prompt/composer.ts +45 -72
  47. package/src/prompt/custom-editor.ts +24 -6
  48. package/src/prompt/model-mention-autocomplete.ts +22 -10
  49. package/src/prompt/welcome.ts +31 -28
  50. package/src/render/width-aware-text.ts +9 -0
  51. package/src/status-line/component.ts +286 -182
  52. package/src/status-line/metrics.ts +3 -21
  53. package/src/status-line/segments.ts +68 -79
  54. package/src/status-line/startup.ts +181 -0
  55. package/src/status-line/types.ts +0 -2
  56. package/src/terminal-capabilities.ts +12 -6
  57. package/src/terminal.ts +75 -20
  58. package/src/theme/color.ts +15 -0
  59. package/src/theme/session-color.ts +26 -4
  60. package/src/theme/shimmer.ts +55 -76
  61. package/src/theme/theme-class.ts +29 -12
  62. package/src/tools/output-meta.ts +11 -3
  63. package/src/tools/vibe.ts +15 -2
  64. package/src/tui.ts +208 -154
  65. package/src/utils.ts +15 -0
@@ -49,7 +49,7 @@ export declare class Theme {
49
49
  * Theme-derived inputs for `getSessionAccentHex`: the accent hex whose
50
50
  * OKLCH weight session accents adopt, the major colors they must not
51
51
  * hue-collide with, and the light-theme surface luminance to contrast
52
- * against.
52
+ * against. Built once per instance and frozen; callers share the object.
53
53
  */
54
54
  get sessionAccentInputs(): SessionAccentTheme;
55
55
  fg(color: ThemeColor, text: string): string;
@@ -73,9 +73,10 @@ export interface LimitsMeta {
73
73
  reached: number;
74
74
  suggestion: number;
75
75
  };
76
+ /** `suggestion` is omitted when the tool is already at its hard cap, so no larger usable limit exists to advise. */
76
77
  resultLimit?: {
77
78
  reached: number;
78
- suggestion: number;
79
+ suggestion?: number;
79
80
  };
80
81
  headLimit?: {
81
82
  reached: number;
@@ -8,6 +8,8 @@ export interface VibeToolDetails {
8
8
  op: VibeOp;
9
9
  /** Live TV-wall snapshot of the owner's worker sessions at (or during) the call. */
10
10
  screens: VibeScreenSnapshot[];
11
+ /** Sessions the director killed, left off `screens`; shown as a count. */
12
+ hiddenKilled?: string[];
11
13
  spawned?: {
12
14
  id: string;
13
15
  cli: VibeCli;
@@ -38,6 +40,8 @@ export interface VibeScreenSnapshot {
38
40
  id: string;
39
41
  cli: VibeCli;
40
42
  state: VibeSessionState;
43
+ /** Terminated by the director (`vibe_kill`) or mode teardown; unset for workers that died on their own. */
44
+ killed?: boolean;
41
45
  model?: string;
42
46
  turns: number;
43
47
  queued: number;
@@ -273,8 +273,9 @@ export declare class Container implements Component {
273
273
  /**
274
274
  * Merge runs of byte-adjacent SGR sequences (`CSI [0-9;:]* m`) into one. Only
275
275
  * CSI-SGR sequences are touched; text, cursor moves, OSC, hyperlinks and image
276
- * payloads pass through verbatim. Returns the original reference when nothing
277
- * merges, so SGR-light lines incur only a single `indexOf` scan.
276
+ * payloads pass through verbatim. Isolated sequences require no parameter
277
+ * slices or arrays; output is built only at merged boundaries or empty resets
278
+ * that need normalization within an adjacent run.
278
279
  */
279
280
  export declare function coalesceAdjacentSgr(line: string): string;
280
281
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.4.0",
4
+ "version": "18.4.2",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.4.0",
45
- "@oh-my-pi/pi-agent-core": "18.4.0",
46
- "@oh-my-pi/pi-ai": "18.4.0",
47
- "@oh-my-pi/pi-catalog": "18.4.0",
48
- "@oh-my-pi/pi-natives": "18.4.0",
49
- "@oh-my-pi/pi-utils": "18.4.0",
50
- "@oh-my-pi/pi-wire": "18.4.0",
51
- "@oh-my-pi/snapcompact": "18.4.0"
44
+ "@oh-my-pi/omptype": "18.4.2",
45
+ "@oh-my-pi/pi-agent-core": "18.4.2",
46
+ "@oh-my-pi/pi-ai": "18.4.2",
47
+ "@oh-my-pi/pi-catalog": "18.4.2",
48
+ "@oh-my-pi/pi-natives": "18.4.2",
49
+ "@oh-my-pi/pi-utils": "18.4.2",
50
+ "@oh-my-pi/pi-wire": "18.4.2",
51
+ "@oh-my-pi/snapcompact": "18.4.2"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -12,7 +12,7 @@ import type { AssistantThinkingRenderer } from "./extension-types";
12
12
  import { ensureThemeSync, getMarkdownTheme, theme } from "../theme";
13
13
  import { EMPTY_LINK_TARGETS, resolveImageOptions } from "../render/render-utils";
14
14
  import { WidthAwareText } from "../render";
15
- import { convertImageToPng } from "./image-loading";
15
+ import { cachedPngConversion, convertImageToPngShared, imagePayloadKey } from "./image-loading";
16
16
  import { canonicalizeMessage, formatThinkingForDisplay, hasDisplayableThinking } from "./thinking-display";
17
17
  import { resolveAssistantErrorPresentation } from "./transcript-render-helpers";
18
18
  import { type CacheInvalidation, CacheInvalidationMarkerComponent } from "./cache-invalidation-marker";
@@ -186,10 +186,20 @@ export class AssistantMessageComponent extends Container {
186
186
  #lastMessage?: AssistantMessage;
187
187
  #emergencyText?: Markdown;
188
188
  #toolImagesByCallId = new Map<string, ImageContent[]>();
189
- #convertedKittyImages = new Map<string, ImageContent>();
189
+ /**
190
+ * Payload keys ({@link imagePayloadKey}) whose Kitty PNG conversion this
191
+ * component already awaits, so a re-delivered image neither re-encodes nor
192
+ * schedules a second {@link updateContent} cascade. The conversions
193
+ * themselves are shared process-wide by {@link convertImageToPngShared}.
194
+ */
195
+ #kittyConversionsAwaited = new Set<string>();
196
+ /**
197
+ * Conversions this component displays, held so a later re-render (theme
198
+ * invalidation) still finds them after the bounded shared cache evicts them.
199
+ */
200
+ #kittyConverted = new Map<string, ImageContent>();
190
201
  #showImages = true;
191
202
  #showToolResultImages = true;
192
- #kittyConversionsInFlight = new Set<string>();
193
203
  #transcriptBlockFinalized: boolean;
194
204
  /** See {@link setMidStreamPublication}; the wire's `stream-revision` axis decides it. */
195
205
  #midStreamPublication = true;
@@ -832,16 +842,6 @@ export class AssistantMessageComponent extends Container {
832
842
  setToolResultImages(toolCallId: string, images: ImageContent[]): void {
833
843
  if (!toolCallId) return;
834
844
  const validImages = images.filter(img => img.type === "image" && img.data && img.mimeType);
835
- for (const key of Array.from(this.#convertedKittyImages.keys())) {
836
- if (key.startsWith(`${toolCallId}:`)) {
837
- this.#convertedKittyImages.delete(key);
838
- }
839
- }
840
- for (const key of Array.from(this.#kittyConversionsInFlight)) {
841
- if (key.startsWith(`${toolCallId}:`)) {
842
- this.#kittyConversionsInFlight.delete(key);
843
- }
844
- }
845
845
  if (validImages.length === 0) {
846
846
  this.#toolImagesByCallId.delete(toolCallId);
847
847
  } else {
@@ -855,21 +855,27 @@ export class AssistantMessageComponent extends Container {
855
855
 
856
856
  #convertImagesForKitty(entries: Array<{ image: ImageContent; key: string }>): void {
857
857
  if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
858
- for (const { image, key } of entries) {
858
+ for (const { image } of entries) {
859
859
  if (image.mimeType === "image/png") continue;
860
- if (this.#convertedKittyImages.has(key) || this.#kittyConversionsInFlight.has(key)) continue;
861
- this.#kittyConversionsInFlight.add(key);
862
- convertImageToPng(image)
860
+ const key = imagePayloadKey(image);
861
+ if (this.#kittyConverted.has(key)) continue;
862
+ const cached = cachedPngConversion(image);
863
+ if (cached) {
864
+ this.#kittyConverted.set(key, cached);
865
+ continue;
866
+ }
867
+ if (this.#kittyConversionsAwaited.has(key)) continue;
868
+ this.#kittyConversionsAwaited.add(key);
869
+ convertImageToPngShared(image)
863
870
  .then(converted => {
864
- this.#kittyConversionsInFlight.delete(key);
865
- this.#convertedKittyImages.set(key, converted);
871
+ this.#kittyConverted.set(key, converted);
866
872
  if (this.#lastMessage) {
867
873
  this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
868
874
  }
869
875
  this.#onImageUpdate?.();
870
876
  })
871
877
  .catch(() => {
872
- this.#kittyConversionsInFlight.delete(key);
878
+ this.#kittyConversionsAwaited.delete(key);
873
879
  });
874
880
  }
875
881
  }
@@ -882,7 +888,7 @@ export class AssistantMessageComponent extends Container {
882
888
  for (const { image, key } of entries) {
883
889
  const displayImage =
884
890
  TERMINAL.imageProtocol === ImageProtocol.Kitty && image.mimeType !== "image/png"
885
- ? this.#convertedKittyImages.get(key)
891
+ ? this.#kittyConverted.get(imagePayloadKey(image))
886
892
  : image;
887
893
  if (TERMINAL.imageProtocol && displayImage) {
888
894
  this.#contentContainer.addChild(
@@ -110,6 +110,87 @@ export async function convertImageToPng(image: ImageContent): Promise<ImageConte
110
110
  return { ...image, data, mimeType: "image/png" };
111
111
  }
112
112
 
113
+ /**
114
+ * Byte ceiling for {@link convertImageToPngShared}'s resident conversions.
115
+ * Converted PNGs are larger than the webp/jpeg they came from, so the cache is
116
+ * bounded and evicts least-recently-used entries rather than growing with the
117
+ * session's image history.
118
+ */
119
+ const PNG_CACHE_MAX_BYTES = 32 * 1024 * 1024;
120
+
121
+ /** Least-recently-used first; {@link touchPngCache} re-inserts on hit. */
122
+ const pngCache = new Map<string, ImageContent>();
123
+ let pngCacheBytes = 0;
124
+ const pngConversionsInFlight = new Map<string, Promise<ImageContent>>();
125
+
126
+ /** Cache lookup that also marks `key` as most recently used. */
127
+ function touchPngCache(key: string): ImageContent | undefined {
128
+ const hit = pngCache.get(key);
129
+ if (!hit) return undefined;
130
+ pngCache.delete(key);
131
+ pngCache.set(key, hit);
132
+ return hit;
133
+ }
134
+
135
+ /**
136
+ * Content-addressed identity of an image payload, stable across components and
137
+ * transcript rebuilds. Callers key their own per-image state by this instead of
138
+ * positional ids like `${toolCallId}:${index}`, which go stale when the images
139
+ * behind a position are replaced.
140
+ */
141
+ export function imagePayloadKey(image: ImageContent): string {
142
+ return `${image.mimeType}:${image.data.length}:${Bun.hash(image.data)}`;
143
+ }
144
+
145
+ /**
146
+ * The PNG conversion of `image` when one is still resident, else `undefined`.
147
+ * Synchronous so renderers can use an already-converted image on the spot
148
+ * instead of scheduling another async re-render.
149
+ */
150
+ export function cachedPngConversion(image: ImageContent): ImageContent | undefined {
151
+ return touchPngCache(imagePayloadKey(image));
152
+ }
153
+
154
+ /**
155
+ * Converts `image` to PNG at most once per distinct payload: concurrent callers
156
+ * share one in-flight conversion and later callers hit {@link pngCache}.
157
+ * Kitty-graphics renderers use this because `convertImageToPng` is a full
158
+ * decode plus re-encode, and the same image is delivered repeatedly (read-result
159
+ * replay) and rebuilt from scratch on resume, rewind, and `/tree` navigation.
160
+ *
161
+ * Rejections are not cached — a payload that failed to decode is retried by the
162
+ * next caller, matching {@link convertImageToPng}'s contract.
163
+ */
164
+ export function convertImageToPngShared(image: ImageContent): Promise<ImageContent> {
165
+ const key = imagePayloadKey(image);
166
+ const cached = touchPngCache(key);
167
+ if (cached) return Promise.resolve(cached);
168
+ const running = pngConversionsInFlight.get(key);
169
+ if (running) return running;
170
+ const conversion = convertImageToPng(image)
171
+ .then(({ data }) => {
172
+ pngConversionsInFlight.delete(key);
173
+ // Only the payload is shared; caller-specific fields (`url`,
174
+ // `providerFile`, `detail`) describe the source image, not this PNG.
175
+ const converted: ImageContent = { type: "image", data, mimeType: "image/png" };
176
+ pngCache.set(key, converted);
177
+ pngCacheBytes += data.length;
178
+ for (const [oldest, entry] of pngCache) {
179
+ if (pngCacheBytes <= PNG_CACHE_MAX_BYTES) break;
180
+ if (oldest === key) continue;
181
+ pngCache.delete(oldest);
182
+ pngCacheBytes -= entry.data.length;
183
+ }
184
+ return converted;
185
+ })
186
+ .catch(error => {
187
+ pngConversionsInFlight.delete(key);
188
+ throw error;
189
+ });
190
+ pngConversionsInFlight.set(key, conversion);
191
+ return conversion;
192
+ }
193
+
113
194
  export async function ensureSupportedImageInput(image: ImageContent): Promise<ImageContent | null> {
114
195
  if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) {
115
196
  return image;
@@ -1,3 +1,4 @@
1
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
1
2
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
3
  import { Box } from "../components/box";
3
4
  import { SPINNER_ADVANCE_MS } from "../components/loader";
@@ -26,7 +27,7 @@ import { isWaitingPollDetails } from "../tools/wait";
26
27
  import { formatStatusIcon, replaceTabs, resolveImageOptions } from "../render/render-utils";
27
28
  import type { XdevMountedState } from "../tools/xdev";
28
29
  import { isFramedBlockComponent, markFramedBlockComponent, renderStatusLine, WidthAwareText } from "../render/index";
29
- import { convertImageToPng } from "./image-loading";
30
+ import { cachedPngConversion, convertImageToPngShared, imagePayloadKey } from "./image-loading";
30
31
  import { sanitizeWithOptionalSixelPassthrough } from "../render/sixel";
31
32
  import { renderDiff } from "../chrome/diff";
32
33
  import { type AnimationFrame, trimBlankEdges } from "../chrome/transcript-container";
@@ -284,6 +285,14 @@ export class ToolExecutionComponent extends Container {
284
285
  // so a terminal resize re-shapes image-bearing results to rescale them without
285
286
  // forcing the common image-free result to re-shape on every resize tick.
286
287
  #renderedImageCount = 0;
288
+ // `stateBgKey|themeEpoch` of the tint last handed to #contentText. Re-tinting
289
+ // drops its wrap cache, so a rebuild whose tint is unchanged skips it and the
290
+ // inner Text re-wraps only when the reformatted card text actually differs.
291
+ #contentTextBgKey: string | undefined;
292
+ // Memoized #getTextOutput(), keyed by every input it reads: the result
293
+ // (versioned by #resultVersion), #showImages, and the image protocol.
294
+ #textOutput = "";
295
+ #textOutputKey: string | undefined;
287
296
  #tool?: AgentTool;
288
297
  #renderer?: ToolRenderer;
289
298
  #ui: ToolExecutionUi;
@@ -296,8 +305,13 @@ export class ToolExecutionComponent extends Container {
296
305
  #editMode?: EditMode;
297
306
  #editDiffPreview?: PerFileDiffPreview[];
298
307
  #previewReady?: PromiseWithResolvers<void>;
299
- // Cached converted images for Kitty protocol (which requires PNG), keyed by index
300
- #convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
308
+ // Payload keys whose Kitty PNG conversion is already awaited; the converted
309
+ // images themselves live in the process-wide cache behind
310
+ // `convertImageToPngShared`, so rebuilt components reuse them.
311
+ #kittyConversionsAwaited = new Set<string>();
312
+ // Conversions this component displays, held so a later re-render still finds
313
+ // them after the bounded shared cache evicts them.
314
+ #kittyConverted = new Map<string, ImageContent>();
301
315
  // Spinner animation for partial task results
302
316
  #spinnerFrame?: number;
303
317
  #spinnerActive = false;
@@ -545,26 +559,32 @@ export class ToolExecutionComponent extends Container {
545
559
  if (TERMINAL.imageProtocol !== ImageProtocol.Kitty) return;
546
560
  if (!this.#result) return;
547
561
 
548
- const imageBlocks = this.#getAllImageBlocks();
549
-
550
- for (let i = 0; i < imageBlocks.length; i++) {
551
- const img = imageBlocks[i];
562
+ for (const img of this.#getAllImageBlocks()) {
552
563
  if (!img.data || !img.mimeType) continue;
553
- // Skip if already PNG or already converted
564
+ // Skip if already PNG or already converted anywhere in this process
554
565
  if (img.mimeType === "image/png") continue;
555
- if (this.#convertedImages.has(i)) continue;
566
+ const image: ImageContent = { type: "image", data: img.data, mimeType: img.mimeType };
567
+ const key = imagePayloadKey(image);
568
+ if (this.#kittyConverted.has(key)) continue;
569
+ const cached = cachedPngConversion(image);
570
+ if (cached) {
571
+ this.#kittyConverted.set(key, cached);
572
+ continue;
573
+ }
574
+ if (this.#kittyConversionsAwaited.has(key)) continue;
575
+ this.#kittyConversionsAwaited.add(key);
556
576
 
557
577
  // Convert async - catch errors from processing
558
- const index = i;
559
- convertImageToPng({ type: "image", data: img.data, mimeType: img.mimeType })
578
+ convertImageToPngShared(image)
560
579
  .then(converted => {
561
- this.#convertedImages.set(index, converted);
580
+ this.#kittyConverted.set(key, converted);
562
581
  this.#displayInputVersion++;
563
582
  this.#updateDisplay();
564
583
  this.#ui.requestRender();
565
584
  })
566
585
  .catch(() => {
567
586
  // Ignore conversion failures - display will use original image format
587
+ this.#kittyConversionsAwaited.delete(key);
568
588
  });
569
589
  }
570
590
  }
@@ -1188,8 +1208,7 @@ export class ToolExecutionComponent extends Container {
1188
1208
  // Generic fallback (no custom/built-in renderer). WidthAwareText
1189
1209
  // reformats at render time so output fills the actual terminal width
1190
1210
  // instead of a fixed column cap.
1191
- this.#contentText.setCustomBgFn(stateBgFn);
1192
- this.#contentText.invalidate();
1211
+ this.#refreshContentText(stateBgKey, stateBgFn);
1193
1212
  }
1194
1213
 
1195
1214
  // Handle images (same for both custom and built-in)
@@ -1209,7 +1228,11 @@ export class ToolExecutionComponent extends Container {
1209
1228
  const img = imageBlocks[i];
1210
1229
  if (TERMINAL.imageProtocol && this.#showImages && img.data && img.mimeType) {
1211
1230
  // Use converted PNG for Kitty protocol if available
1212
- const converted = this.#convertedImages.get(i);
1231
+ const source: ImageContent = { type: "image", data: img.data, mimeType: img.mimeType };
1232
+ const converted =
1233
+ TERMINAL.imageProtocol === ImageProtocol.Kitty && img.mimeType !== "image/png"
1234
+ ? (this.#kittyConverted.get(imagePayloadKey(source)) ?? cachedPngConversion(source))
1235
+ : undefined;
1213
1236
  const imageData = converted?.data ?? img.data;
1214
1237
  const imageMimeType = converted?.mimeType ?? img.mimeType;
1215
1238
 
@@ -1323,6 +1346,8 @@ export class ToolExecutionComponent extends Container {
1323
1346
 
1324
1347
  #getTextOutput(): string {
1325
1348
  if (!this.#result) return "";
1349
+ const key = `${this.#resultVersion}|${this.#showImages}|${TERMINAL.imageProtocol ?? "-"}`;
1350
+ if (key === this.#textOutputKey) return this.#textOutput;
1326
1351
 
1327
1352
  const textBlocks = this.#result.content.filter(c => c.type === "text");
1328
1353
  const imageBlocks = this.#getAllImageBlocks();
@@ -1344,9 +1369,21 @@ export class ToolExecutionComponent extends Container {
1344
1369
  output = output ? `${output}\n${imageIndicators}` : imageIndicators;
1345
1370
  }
1346
1371
 
1372
+ this.#textOutputKey = key;
1373
+ this.#textOutput = output;
1347
1374
  return output;
1348
1375
  }
1349
1376
 
1377
+ /** Re-tint (only when the tint changed) and reformat the generic #contentText card. */
1378
+ #refreshContentText(stateBgKey: string, stateBgFn: (text: string) => string): void {
1379
+ const bgKey = `${stateBgKey}|${getThemeEpoch()}`;
1380
+ if (bgKey !== this.#contentTextBgKey) {
1381
+ this.#contentTextBgKey = bgKey;
1382
+ this.#contentText.setCustomBgFn(stateBgFn);
1383
+ }
1384
+ this.#contentText.reformat();
1385
+ }
1386
+
1350
1387
  /**
1351
1388
  * Format the generic call/result card at `contentWidth`. Shared by the
1352
1389
  * #contentText fallback and the benign-skip path so both render identically.
@@ -1390,8 +1427,7 @@ export class ToolExecutionComponent extends Container {
1390
1427
  */
1391
1428
  #renderBenignSkipCard(stateBgFn: (text: string) => string): void {
1392
1429
  if (!this.#usesContentBox) {
1393
- this.#contentText.setCustomBgFn(stateBgFn);
1394
- this.#contentText.invalidate();
1430
+ this.#refreshContentText("toolPendingBg", stateBgFn);
1395
1431
  return;
1396
1432
  }
1397
1433
  for (const box of this.#multiFileBoxes) {
@@ -68,6 +68,22 @@ export function isUserRequestEntry(entry: TranscriptEntryLike | { type: string }
68
68
  return false;
69
69
  }
70
70
 
71
+ /**
72
+ * Recent transcript tail starting at a user-request boundary, so tool calls
73
+ * and results stay together. ChatTranscriptBuilder drops a tool result whose
74
+ * initiating call was sliced away, so a tail of orphaned results can leave
75
+ * the picker without any target.
76
+ *
77
+ * A whole user turn may exceed `limit`; paginate rendering if one turn grows too large.
78
+ */
79
+ export function recentTranscriptEntries(entries: TranscriptEntryLike[], limit = 600): TranscriptEntryLike[] {
80
+ if (entries.length <= limit) return entries;
81
+ for (let index = entries.length - limit; index > 0; index--) {
82
+ if (isUserRequestEntry(entries[index]!)) return entries.slice(index);
83
+ }
84
+ return entries;
85
+ }
86
+
71
87
  /** Editable user request text, preserving skill invocation syntax. */
72
88
  export function userTurnDraft(entry: TranscriptEntryLike): string | undefined {
73
89
  const message = transcriptEntryMessage(entry);