@oh-my-pi/pi-tui 18.10.1 → 18.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,7 +32,7 @@ import { Stack } from "../../components/layout/stack";
32
32
  import { matchesKey } from "../../keys";
33
33
  import { ProcessTerminal } from "../../terminal";
34
34
  import { routeSgrMouseInput } from "../../mouse";
35
- import { col, compact, keyed, node, row, span, text } from "../../native/describe";
35
+ import { col, compact, keyed, node, row, span, stableKey, text } from "../../native/describe";
36
36
  import { Memo } from "../../native/memo";
37
37
  import type { DescribeContext, NativeNode, NativeUiEvent } from "../../native/node";
38
38
  import { actionButton } from "../../native/overlay";
@@ -42,6 +42,7 @@ import { theme, warmHighlighter } from "../../theme/theme";
42
42
  import type { AvatarSource } from "./avatar";
43
43
  import { pill, softPill, tintChip } from "./colors";
44
44
  import {
45
+ assetTitle,
45
46
  buildDiffDocument,
46
47
  type DiffDocument,
47
48
  buildLineSelectionPatch,
@@ -53,7 +54,7 @@ import {
53
54
  } from "./diff-pane";
54
55
  import { GitHelpSheet } from "./help";
55
56
  import { Sidebar, type SidebarAction, type GitCommitMessage } from "./sidebar";
56
- import type { AssetFileContents, ChangedFile, FileContents, GitTuiModel } from "./state";
57
+ import type { AssetFileContents, ChangedFile, FileAssetSide, FileContents, GitTuiModel, MediaRef } from "./state";
57
58
 
58
59
  /** AI staging counts displayed after filtering the working tree. */
59
60
  export interface AiStageOutcome {
@@ -929,8 +930,9 @@ class GitTuiComponent implements Component {
929
930
  this.#contents,
930
931
  this.#currentFile,
931
932
  this.#pane.emptyMessage,
933
+ cx.supports("media"),
932
934
  ],
933
- () => this.#describeBody(),
935
+ () => this.#describeBody(cx),
934
936
  );
935
937
  const side = this.#sidebar.describe(cx);
936
938
  return this.#nativeRoot.get([bar, body, side, narrow, status, this.#status, this.#statusTone], () => {
@@ -1086,7 +1088,7 @@ class GitTuiComponent implements Component {
1086
1088
  }
1087
1089
 
1088
1090
  /** The diff for the view mode, or the loading, empty and media states. */
1089
- #describeBody(): NativeNode {
1091
+ #describeBody(cx: DescribeContext): NativeNode {
1090
1092
  const doc = this.#pane.doc;
1091
1093
  const file = this.#currentFile;
1092
1094
  if (doc && this.#pane.state === "ready") {
@@ -1104,6 +1106,18 @@ class GitTuiComponent implements Component {
1104
1106
  }
1105
1107
  const contents = this.#contents;
1106
1108
  if (this.#pane.state === "asset" && file && contents?.kind === "asset") {
1109
+ if (cx.supports("media")) {
1110
+ return keyed(
1111
+ row(
1112
+ [
1113
+ mediaColumn("before", contents.old, contents.oldRef),
1114
+ mediaColumn("after", contents.new, contents.newRef),
1115
+ ],
1116
+ { gap: "sm", align: "stretch", grow: 1, role: "omp.app.git.compare" },
1117
+ ),
1118
+ "compare",
1119
+ );
1120
+ }
1107
1121
  return keyed(
1108
1122
  col(
1109
1123
  [
@@ -1190,6 +1204,41 @@ function unifiedDiff(doc: DiffDocument, whole: boolean): string {
1190
1204
  return out.join("\n");
1191
1205
  }
1192
1206
 
1207
+ /** `media` nodes of the before/after compare share this link, so they pan and zoom together. */
1208
+ const MEDIA_COMPARE_LINK = "git-compare";
1209
+
1210
+ /**
1211
+ * One column of the native media compare: its heading (`Before · PNG`) over a
1212
+ * `media` node Tern loads from `ref`, keyed by path so another file remounts,
1213
+ * or a muted line when the side has no file.
1214
+ */
1215
+ function mediaColumn(side: "before" | "after", asset: FileAssetSide, ref: MediaRef | undefined): NativeNode {
1216
+ const head = keyed(
1217
+ text([span(assetTitle(side === "before" ? "Before" : "After", asset), "strong")], {
1218
+ truncate: "end",
1219
+ role: "omp.app.git.media-head",
1220
+ }),
1221
+ "head",
1222
+ );
1223
+ const body = ref
1224
+ ? node(
1225
+ "media",
1226
+ { ...ref, link: MEDIA_COMPARE_LINK, grow: 1, role: "omp.app.git.media" },
1227
+ undefined,
1228
+ `media-${stableKey(ref.path)}`,
1229
+ )
1230
+ : keyed(
1231
+ text(
1232
+ asset.kind === "empty"
1233
+ ? [span(side === "before" ? "Did not exist" : "Deleted", "muted")]
1234
+ : assetText(asset).map(fact => span(fact.t, "muted")),
1235
+ { role: "omp.app.git.media-none" },
1236
+ ),
1237
+ "none",
1238
+ );
1239
+ return keyed(col([head, body], { gap: "xs", grow: 1, basis: 0, role: "omp.app.git.media-side" }), side);
1240
+ }
1241
+
1193
1242
  /** One side of a media/binary change as a short fact line. */
1194
1243
  function assetText(asset: AssetFileContents["old"]): TspSpan[] {
1195
1244
  const size = (bytes: number | undefined): string =>
@@ -66,11 +66,23 @@ export interface TextFileContents {
66
66
  readonly streamResult: DiffStreamResult;
67
67
  }
68
68
 
69
+ /** Where one side of a change lives, so a host viewer (Tern's TSP `media` node) can load it by path. */
70
+ export interface MediaRef {
71
+ /** Absolute path of the file (repository root joined with its repository path). */
72
+ readonly path: string;
73
+ /** Git revision holding this side (`HEAD`, a sha, `:0` for the index); absent for the worktree file. */
74
+ readonly rev?: string;
75
+ }
76
+
69
77
  /** Non-text sides rendered as previews or explicit placeholders. */
70
78
  export interface AssetFileContents {
71
79
  readonly kind: "asset";
72
80
  readonly old: FileAssetSide;
73
81
  readonly new: FileAssetSide;
82
+ /** Source of {@link old}; absent when that side is empty. */
83
+ readonly oldRef?: MediaRef;
84
+ /** Source of {@link new}; absent when that side is empty. */
85
+ readonly newRef?: MediaRef;
74
86
  }
75
87
 
76
88
  /** Loaded file content selected for text diffing or asset preview. */
@@ -17,9 +17,9 @@ import type { AssistantThinkingRenderer } from "./extension-types";
17
17
  import { ensureThemeSync, getMarkdownTheme, getMarkdownThemeWithLinkTargets, getThemeEpoch, theme } from "../theme";
18
18
  import { card, col, elapsed, node, span, text } from "../native/describe";
19
19
  import { hasTranscriptActions, runTranscriptAction } from "./transcript-actions";
20
- import type { NativeChild, NativeNode, NativeUiEvent } from "../native/node";
20
+ import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
21
21
  import { isNativeRendering } from "../native/state";
22
- import { NativeImageCache } from "../native/blobs";
22
+ import { NativeImageCache, nativeMediaNode } from "../native/blobs";
23
23
  import { Memo } from "../native/memo";
24
24
  import { EMPTY_LINK_TARGETS, resolveImageOptions } from "../render/render-utils";
25
25
  import { WidthAwareText } from "../render";
@@ -250,6 +250,8 @@ export class AssistantMessageComponent extends Container {
250
250
  #lastMessage?: AssistantMessage;
251
251
  #emergencyText?: ProseBlock;
252
252
  #toolImagesByCallId = new Map<string, ImageContent[]>();
253
+ /** File each grouped read showed that Tern's media viewer shows, for native `media` nodes. */
254
+ #toolMediaPathByCallId = new Map<string, string>();
253
255
  /**
254
256
  * Payload keys ({@link imagePayloadKey}) whose Kitty PNG conversion this
255
257
  * component is awaiting, so a re-delivered image neither re-encodes nor
@@ -798,7 +800,8 @@ export class AssistantMessageComponent extends Container {
798
800
  * head's `title`. With `hideThinkingBlock` only the live head shows (collapsed)
799
801
  * and a finished thought emits nothing.
800
802
  */
801
- override describe(): NativeNode {
803
+ override describe(cx?: DescribeContext): NativeNode {
804
+ const media = cx?.supports("media") === true;
802
805
  const tail = this.#displayedMessage ? this.#thinkingTailIndex(this.#displayedMessage) : undefined;
803
806
  const rate =
804
807
  tail !== undefined && this.#lastUpdateTransient && this.#thinkingRateLive
@@ -813,9 +816,10 @@ export class AssistantMessageComponent extends Container {
813
816
  this.#errorExpanded,
814
817
  this.#nativeViewVersion,
815
818
  rate,
819
+ media,
816
820
  getThemeEpoch(),
817
821
  ];
818
- return this.#native.get(key, () => this.#describeMessage(tail, rate));
822
+ return this.#native.get(key, () => this.#describeMessage(tail, rate, media));
819
823
  }
820
824
 
821
825
  /**
@@ -841,7 +845,7 @@ export class AssistantMessageComponent extends Container {
841
845
  this.#nativeViewVersion++;
842
846
  }
843
847
 
844
- #describeMessage(thinkingTail: number | undefined, rate: number): NativeNode {
848
+ #describeMessage(thinkingTail: number | undefined, rate: number, media: boolean): NativeNode {
845
849
  const message = this.#displayedMessage;
846
850
  const previousParts = this.#nativeParts;
847
851
  const parts = new Map<string, { text: string; stream: boolean; node: NativeNode }>();
@@ -962,7 +966,17 @@ export class AssistantMessageComponent extends Container {
962
966
  }
963
967
  }
964
968
  if (this.#showImages && this.#showToolResultImages) {
969
+ // Reads of files Tern's media viewer shows: the host loads each file once by path, no bytes uploaded.
970
+ const shownPaths = new Set<string>();
971
+ if (media) {
972
+ for (const [toolCallId, mediaPath] of this.#toolMediaPathByCallId) {
973
+ if (shownPaths.has(mediaPath)) continue;
974
+ shownPaths.add(mediaPath);
975
+ children.push(nativeMediaNode(mediaPath, `r${toolCallId}`));
976
+ }
977
+ }
965
978
  for (const [toolCallId, images] of this.#toolImagesByCallId) {
979
+ if (media && this.#toolMediaPathByCallId.has(toolCallId)) continue;
966
980
  images.forEach((image, index) => {
967
981
  children.push(this.#nativeImages.get(`r${toolCallId}:${index}`, image.data, image.mimeType));
968
982
  });
@@ -1521,8 +1535,15 @@ export class AssistantMessageComponent extends Container {
1521
1535
  }
1522
1536
  }
1523
1537
 
1524
- setToolResultImages(toolCallId: string, images: ImageContent[]): void {
1538
+ /**
1539
+ * A grouped read's result images, and `mediaPath`: the file it read that Tern's media viewer
1540
+ * shows (read details), which a native host supporting `media` shows by path instead of the
1541
+ * images. A media-only result (a model, a PDF) passes no images.
1542
+ */
1543
+ setToolResultImages(toolCallId: string, images: ImageContent[], mediaPath?: string): void {
1525
1544
  if (!toolCallId) return;
1545
+ if (mediaPath) this.#toolMediaPathByCallId.set(toolCallId, mediaPath);
1546
+ else this.#toolMediaPathByCallId.delete(toolCallId);
1526
1547
  const validImages = images.filter(img => img.type === "image" && img.data && img.mimeType);
1527
1548
  if (validImages.length === 0) {
1528
1549
  this.#toolImagesByCallId.delete(toolCallId);
@@ -32,7 +32,7 @@ import {
32
32
  import { plainText } from "../native/spans";
33
33
  import { diagnosticsSection, displayPath, toolHead } from "../tools/native-view";
34
34
  import type { FileDiagnosticsResult } from "../tools/lsp";
35
- import { NativeImageCache } from "../native/blobs";
35
+ import { NativeImageCache, readMediaPath, shownMedia } from "../native/blobs";
36
36
  import { Memo } from "../native/memo";
37
37
  import { type EditMode, type PerFileDiffPreview, renderStreamingFallback } from "../tools/edit";
38
38
  import { taskCardAgentIds } from "../tools/task";
@@ -218,18 +218,17 @@ export interface ToolExecutionOptions {
218
218
  useBuiltInRenderer?: boolean;
219
219
  }
220
220
 
221
+ /** A tool result or progress frame as a card shows it. */
222
+ export interface ToolResultFrame {
223
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
224
+ details?: unknown;
225
+ isError?: boolean;
226
+ }
227
+
221
228
  export interface ToolExecutionHandle extends Component {
222
229
  updateArgs(args: unknown, toolCallId?: string): void;
223
230
  updateStreamPreview?(update: unknown): void;
224
- updateResult(
225
- result: {
226
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
227
- details?: unknown;
228
- isError?: boolean;
229
- },
230
- isPartial?: boolean,
231
- toolCallId?: string,
232
- ): void;
231
+ updateResult(result: ToolResultFrame, isPartial?: boolean, toolCallId?: string): void;
233
232
  setArgsComplete(toolCallId?: string): void;
234
233
  setExecutionStarted(toolCallId?: string): void;
235
234
  setExpanded(expanded: boolean): void;
@@ -240,6 +239,50 @@ export interface ToolExecutionHandle extends Component {
240
239
  seal(): void;
241
240
  }
242
241
 
242
+ /** Tools whose returned call hands its card to the background job it started (see {@link parksAsBackground}). */
243
+ const JOB_CARD_TOOLS: Record<string, "always" | "native"> = { task: "always", bash: "native", eval: "native" };
244
+
245
+ /**
246
+ * Whether a call that returned with `details.async.state === "running"` stays
247
+ * parked ({@link ToolExecutionHandle.parkAsBackground}) so its job's later
248
+ * frames keep filling the card until a `completed`/`failed` frame settles it.
249
+ * Detached `task` spawns always do; backgrounded `bash`/`eval` jobs only under
250
+ * native rendering, where the terminal transcript keeps them frozen instead.
251
+ * Shared by the live event controller and the transcript rebuild.
252
+ */
253
+ export function parksAsBackground(toolName: string, details: unknown): boolean {
254
+ const mode = JOB_CARD_TOOLS[toolName];
255
+ if (mode === undefined || (mode === "native" && !isNativeRendering())) return false;
256
+ return asyncJobState(details) === "running";
257
+ }
258
+
259
+ /** The background job state a tool result reports (`details.async.state`: running, completed, failed, cancelled). */
260
+ export function asyncJobState(details: unknown): string | undefined {
261
+ if (!isRecord(details) || !isRecord(details.async)) return undefined;
262
+ return typeof details.async.state === "string" ? details.async.state : undefined;
263
+ }
264
+
265
+ /** Whether a frame is a background job's last one: it completed, failed or was cancelled. */
266
+ export function isSettledJobFrame(details: unknown): boolean {
267
+ const state = asyncJobState(details);
268
+ return state === "completed" || state === "failed" || state === "cancelled";
269
+ }
270
+
271
+ /**
272
+ * Settle a parked card ({@link parksAsBackground}) with its job's last frame
273
+ * ({@link isSettledJobFrame}): a completed or failed job is the call's result;
274
+ * a cancelled one keeps the output it got to and seals the card as cancelled.
275
+ */
276
+ export function settleWithJobFrame(component: ToolExecutionHandle, frame: ToolResultFrame, toolCallId: string): void {
277
+ const state = asyncJobState(frame.details);
278
+ if (state === "cancelled") {
279
+ component.updateResult(frame, true, toolCallId);
280
+ component.seal();
281
+ return;
282
+ }
283
+ component.updateResult({ ...frame, isError: state === "failed" }, false, toolCallId);
284
+ }
285
+
243
286
  /** Card tone per status when the renderer doesn't choose one; settled cards stay neutral. */
244
287
  const NATIVE_STATUS_TONE: Record<TspCardStatus, TspTone | undefined> = {
245
288
  pending: "pending",
@@ -581,15 +624,7 @@ export class ToolExecutionComponent extends Container {
581
624
  return result !== undefined && !this.#isPartial && (result.isError === true || result.details != null);
582
625
  }
583
626
 
584
- updateResult(
585
- result: {
586
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
587
- details?: unknown;
588
- isError?: boolean;
589
- },
590
- isPartial = false,
591
- _toolCallId?: string,
592
- ): void {
627
+ updateResult(result: ToolResultFrame, isPartial = false, _toolCallId?: string): void {
593
628
  const hadNoResult = this.#result === undefined;
594
629
  const wasPartialResult = this.#result !== undefined && this.#isPartial;
595
630
  const firstResultRepaintShapePainted = this.#firstResultViewportRepaintShapePainted;
@@ -941,6 +976,7 @@ export class ToolExecutionComponent extends Container {
941
976
  override describe(cx?: DescribeContext): NativeNode {
942
977
  if (this.#toolName === "wait" && this.#isBenignSkip()) return EMPTY_NODE;
943
978
  const dataFirst = cx?.supports("tool") === true;
979
+ const media = cx?.supports("media") === true;
944
980
  const key = [
945
981
  dataFirst,
946
982
  this.#resultVersion,
@@ -953,10 +989,11 @@ export class ToolExecutionComponent extends Container {
953
989
  this.#showImages,
954
990
  this.#displayInputVersion,
955
991
  this.#toolActivityVisible,
992
+ media,
956
993
  getThemeEpoch(),
957
994
  ];
958
995
  return this.#native.get(key, () =>
959
- withHidden(dataFirst ? this.#describeTool() : this.#describeCard(), !this.#toolActivityVisible),
996
+ withHidden(dataFirst ? this.#describeTool(media) : this.#describeCard(media), !this.#toolActivityVisible),
960
997
  );
961
998
  }
962
999
 
@@ -1002,12 +1039,12 @@ export class ToolExecutionComponent extends Container {
1002
1039
  * The data-first `tool` node (§7.2): the renderer's head data, status,
1003
1040
  * timer, exit and intent as props; its body as borderless children.
1004
1041
  */
1005
- #describeTool(): NativeNode {
1006
- const view = this.#nativeView();
1042
+ #describeTool(media: boolean): NativeNode {
1043
+ const view = this.#nativeView(media);
1007
1044
  const status = this.#nativeStatus();
1008
1045
  const head = view.tool;
1009
1046
  const late = this.#lateDiagnosticsParts();
1010
- const body: NativeChild[] = [...(view.body ?? []), ...this.#nativeResultImages()];
1047
+ const body: NativeChild[] = [...(view.body ?? []), ...this.#nativeResultImages(view.body)];
1011
1048
  if (late.section) body.push(late.section);
1012
1049
  const inline = view.inline === true;
1013
1050
  const hasBody = body.length > 0;
@@ -1072,8 +1109,8 @@ export class ToolExecutionComponent extends Container {
1072
1109
  return spans;
1073
1110
  }
1074
1111
 
1075
- #describeCard(): NativeNode {
1076
- const view = this.#nativeView();
1112
+ #describeCard(media: boolean): NativeNode {
1113
+ const view = this.#nativeView(media);
1077
1114
  const status = this.#nativeStatus();
1078
1115
  const late = this.#lateDiagnosticsParts();
1079
1116
  const fallbackHead = this.#fallbackHead(view);
@@ -1095,7 +1132,7 @@ export class ToolExecutionComponent extends Container {
1095
1132
  const children: NativeChild[] = [
1096
1133
  node("row", { gap: "sm", align: "baseline" }, headChildren, "head"),
1097
1134
  ...(view.body ?? []),
1098
- ...this.#nativeResultImages(),
1135
+ ...this.#nativeResultImages(view.body),
1099
1136
  ...(late.section ? [late.section] : []),
1100
1137
  ];
1101
1138
  const role = `omp.tool.${this.#toolName}`;
@@ -1127,9 +1164,10 @@ export class ToolExecutionComponent extends Container {
1127
1164
  * the result view (merged renderers) or the call head over both bodies.
1128
1165
  * A renderer that throws falls back to the generic card.
1129
1166
  */
1130
- #nativeView(): NativeToolView {
1167
+ #nativeView(media: boolean): NativeToolView {
1131
1168
  const status = this.#nativeStatus();
1132
1169
  const options: RenderResultContextOptions = {
1170
+ media,
1133
1171
  expanded: this.#expanded,
1134
1172
  isPartial: this.#isPartial,
1135
1173
  argsComplete: this.#argsComplete,
@@ -1208,12 +1246,17 @@ export class ToolExecutionComponent extends Container {
1208
1246
  return indicators ? (output ? `${output}\n${indicators}` : indicators) : output;
1209
1247
  }
1210
1248
 
1211
- #nativeResultImages(): NativeNode[] {
1249
+ /** Result images under the renderer's body, minus those the body already shows (as an `image` blob or a `media` file). */
1250
+ #nativeResultImages(body: readonly NativeChild[] | undefined): NativeNode[] {
1212
1251
  if (!this.#result || !this.#showImages) return [];
1252
+ const shown = shownMedia(body);
1253
+ const mediaPath = readMediaPath(this.#result.details);
1254
+ if (mediaPath && shown.paths.has(mediaPath)) return [];
1213
1255
  const images: NativeNode[] = [];
1214
1256
  this.#getAllImageBlocks().forEach((image, index) => {
1215
- if (image.data && image.mimeType)
1216
- images.push(this.#nativeImages.get(`img${index}`, image.data, image.mimeType));
1257
+ if (!image.data || !image.mimeType) return;
1258
+ const described = this.#nativeImages.get(`img${index}`, image.data, image.mimeType);
1259
+ if (!shown.blobs.has((described.p as { blob?: string } | undefined)?.blob ?? "")) images.push(described);
1217
1260
  });
1218
1261
  return images;
1219
1262
  }
@@ -202,12 +202,20 @@ function sameSet(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
202
202
  return true;
203
203
  }
204
204
 
205
+ /**
206
+ * Kinds newer than some Tern builds that answer `TERM_PROGRAM=tern`: used only
207
+ * once the real `hello` lists them, so the optimistic start never emits a node
208
+ * an older terminal cannot draw (`media`: files Tern loads by path).
209
+ */
210
+ const UNASSUMED_KINDS: Partial<Record<TspKind, true>> = { media: true };
211
+
205
212
  /**
206
213
  * The reply a v1 terminal is assumed to give before its real `hello` arrives
207
- * (the `TERM_PROGRAM=tern` optimistic start): the whole vocabulary, the
208
- * default APC limit and credits, the terminal's width, the appearance omp
209
- * already detected, full motion, and `blobs`, so a resumed session's images
210
- * are asked about rather than resent before the reply comes.
214
+ * (the `TERM_PROGRAM=tern` optimistic start): the whole vocabulary except
215
+ * {@link UNASSUMED_KINDS}, the default APC limit and credits, the terminal's
216
+ * width, the appearance omp already detected, full motion, and `blobs`, so a
217
+ * resumed session's images are asked about rather than resent before the
218
+ * reply comes.
211
219
  */
212
220
  export function assumedTspHello(terminal: Terminal): TspHello {
213
221
  const appearance = terminal.appearance;
@@ -215,7 +223,7 @@ export function assumedTspHello(terminal: Terminal): TspHello {
215
223
  r: "hello",
216
224
  v: TSP_VERSION,
217
225
  term: "tern",
218
- kinds: TSP_KINDS,
226
+ kinds: TSP_KINDS.filter(kind => !UNASSUMED_KINDS[kind]),
219
227
  features: ["blobs"],
220
228
  apc: TSP_DEFAULT_APC_LIMIT,
221
229
  credits: TSP_DEFAULT_CREDITS,
@@ -8,9 +8,10 @@
8
8
  * references it.
9
9
  */
10
10
  import type { TspProps } from "@oh-my-pi/pi-wire";
11
+ import { resolveImageOptions } from "../render/render-utils";
11
12
  import { getImageDimensions } from "../terminal-capabilities";
12
13
  import { node } from "./describe";
13
- import type { NativeNode } from "./node";
14
+ import type { NativeChild, NativeNode } from "./node";
14
15
 
15
16
  export interface NativeBlob {
16
17
  readonly id: string;
@@ -103,3 +104,35 @@ export class NativeImageCache {
103
104
  return image;
104
105
  }
105
106
  }
107
+
108
+ /** The disk file a tool result's image was read from (`read` details `mediaPath`), when it has one. */
109
+ export function readMediaPath(details: unknown): string | undefined {
110
+ if (typeof details !== "object" || details === null) return undefined;
111
+ const mediaPath = (details as { mediaPath?: unknown }).mediaPath;
112
+ return typeof mediaPath === "string" && mediaPath.length > 0 ? mediaPath : undefined;
113
+ }
114
+
115
+ /**
116
+ * A `media` node the host loads by path, capped at the inline image preview
117
+ * height. Callers check `cx.supports("media")` first.
118
+ */
119
+ export function nativeMediaNode(path: string, key: string): NativeNode {
120
+ const maxRows = resolveImageOptions().maxHeightCells;
121
+ return node("media", { path, max: maxRows && maxRows > 0 ? { h: `${maxRows}lines` } : undefined }, undefined, key);
122
+ }
123
+
124
+ /** Blob ids of the `image` nodes and paths of the `media` nodes under `children` (described nodes only). */
125
+ export function shownMedia(children: readonly NativeChild[] | undefined): { blobs: Set<string>; paths: Set<string> } {
126
+ const shown = { blobs: new Set<string>(), paths: new Set<string>() };
127
+ const visit = (list: readonly NativeChild[] | undefined): void => {
128
+ for (const child of list ?? []) {
129
+ if (!("k" in child)) continue;
130
+ const p = child.p as { blob?: unknown; path?: unknown } | undefined;
131
+ if (child.k === "image" && typeof p?.blob === "string") shown.blobs.add(p.blob);
132
+ else if (child.k === "media" && typeof p?.path === "string") shown.paths.add(p.path);
133
+ visit(child.c);
134
+ }
135
+ };
136
+ visit(children);
137
+ return shown;
138
+ }
@@ -37,7 +37,14 @@ import {
37
37
  } from "../keybinding-matchers";
38
38
  import { MenuSelection } from "../components/menu-selection";
39
39
  import { clampScrollOffset, scrollOffsetForRow } from "../components/scroll-viewport";
40
- import type { TspPickerColumn, TspPickerGroup, TspPickerItem, TspSpan, TspText } from "@oh-my-pi/pi-wire";
40
+ import type {
41
+ TspPickerColumn,
42
+ TspPickerGroup,
43
+ TspPickerItem,
44
+ TspPickerMark,
45
+ TspSpan,
46
+ TspText,
47
+ } from "@oh-my-pi/pi-wire";
41
48
  import { col, md, node, row, span, text } from "../native/describe";
42
49
  import { pickerFuzzyHits } from "../native/picker";
43
50
  import type { NativeChild, NativeNode, NativeUiEvent } from "../native/node";
@@ -809,6 +816,20 @@ function previewPrice(model: Model): string {
809
816
  return withCreditBadge(model, parts.join(" · "));
810
817
  }
811
818
 
819
+ /**
820
+ * A provider's picker mark: the terminal's logo for it, else its initials
821
+ * (`amazon-bedrock` → `AB`, `anthropic` → `An`) on its own gradient.
822
+ */
823
+ export function providerMark(providerId: string): TspPickerMark {
824
+ const words = providerId.split(/[-_\s.]+/).filter(word => word.length > 0);
825
+ const word = words[0] ?? providerId;
826
+ const text =
827
+ words.length >= 2
828
+ ? `${words[0]!.charAt(0)}${words[1]!.charAt(0)}`.toUpperCase()
829
+ : `${word.charAt(0).toUpperCase()}${word.charAt(1)}`;
830
+ return { text, seed: providerId, icon: `provider:${providerId}` };
831
+ }
832
+
812
833
  /** The omp theme token of a thinking level's dot (`thinkingHigh`); none for inherit and auto. */
813
834
  export function thinkingDotToken(level: ConfiguredThinkingLevel): string | undefined {
814
835
  if (level === ThinkingLevel.Inherit || level === AUTO_THINKING) return undefined;
@@ -1999,6 +2020,7 @@ export class ModelBrowser implements Component {
1999
2020
  const value: TspPickerItem = {
2000
2021
  id: item.selector,
2001
2022
  label: quickRole ? item.id : item.selector,
2023
+ mark: providerMark(item.model.provider),
2002
2024
  mono: true,
2003
2025
  ...(quickRole ? { detail: `${item.model.provider}/${item.model.id}` } : {}),
2004
2026
  facts,
@@ -56,6 +56,7 @@ import {
56
56
  ModelBrowser,
57
57
  type ModelBrowserItem,
58
58
  modelSearchText,
59
+ providerMark,
59
60
  thinkingDotToken,
60
61
  type RoleAssignments,
61
62
  resolveRoleAssignments,
@@ -314,14 +315,6 @@ const MODEL_KIND_LABELS: Record<"all" | ModelKind, string> = {
314
315
  video: "Video",
315
316
  };
316
317
 
317
- /** A provider mark's initials: `amazon-bedrock` → `AB`, `anthropic` → `An`. */
318
- function providerInitials(providerId: string): string {
319
- const words = providerId.split(/[-_\s.]+/).filter(word => word.length > 0);
320
- if (words.length >= 2) return `${words[0]!.charAt(0)}${words[1]!.charAt(0)}`.toUpperCase();
321
- const word = words[0] ?? providerId;
322
- return `${word.charAt(0).toUpperCase()}${word.charAt(1)}`;
323
- }
324
-
325
318
  const PROVIDER_REFRESH_DEBOUNCE_MS = 120;
326
319
  const RECENT_LIMIT = 15;
327
320
  /** Accepted compaction limit input, shown beside the field: a scaled base, a fixed trigger, or a percentage. */
@@ -3458,7 +3451,7 @@ export class ModelHubComponent implements Component {
3458
3451
  break;
3459
3452
  case "provider": {
3460
3453
  const providerId = entry.providerId ?? entry.label;
3461
- const mark = { text: providerInitials(providerId), seed: providerId };
3454
+ const mark = providerMark(providerId);
3462
3455
  if (entry.locked) {
3463
3456
  const envVars = providerEntry(providerId)?.envVars ?? [];
3464
3457
  scopes.push({