@oh-my-pi/pi-tui 18.10.0 → 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.
Files changed (78) hide show
  1. package/CHANGELOG.md +42 -154
  2. package/THIRD-PARTY-NOTICES.txt +3 -3
  3. package/dist/types/app-keybindings.d.ts +8 -8
  4. package/dist/types/apps/git/diff-pane.d.ts +5 -0
  5. package/dist/types/apps/git/state.d.ts +11 -0
  6. package/dist/types/chat/assistant-message.d.ts +9 -4
  7. package/dist/types/chat/tool-execution.d.ts +32 -20
  8. package/dist/types/chat/user-message.d.ts +3 -2
  9. package/dist/types/chrome/countdown-timer.d.ts +3 -2
  10. package/dist/types/native/backend.d.ts +5 -4
  11. package/dist/types/native/blobs.d.ts +13 -1
  12. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  13. package/dist/types/overlays/ask-dialog.d.ts +9 -4
  14. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  15. package/dist/types/overlays/hook-editor.d.ts +7 -2
  16. package/dist/types/overlays/hook-input.d.ts +12 -4
  17. package/dist/types/overlays/jobs-panel.d.ts +8 -7
  18. package/dist/types/overlays/login-dialog.d.ts +8 -3
  19. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  20. package/dist/types/overlays/model-browser.d.ts +6 -1
  21. package/dist/types/overlays/pause-screen.d.ts +5 -0
  22. package/dist/types/overlays/report-panel.d.ts +5 -1
  23. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  24. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  25. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  26. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  27. package/dist/types/status-line/context-usage.d.ts +1 -1
  28. package/dist/types/tools/bash.d.ts +2 -1
  29. package/dist/types/tools/eval.d.ts +2 -2
  30. package/dist/types/tools/grouped-file-output.d.ts +4 -1
  31. package/dist/types/tools/native-view.d.ts +1 -1
  32. package/dist/types/tools/read.d.ts +7 -1
  33. package/dist/types/tools/renderer.d.ts +2 -0
  34. package/package.json +9 -9
  35. package/src/app-keybindings.ts +8 -8
  36. package/src/apps/autoresearch-dashboard.ts +440 -27
  37. package/src/apps/git/diff-pane.ts +39 -35
  38. package/src/apps/git/git-tui.ts +53 -4
  39. package/src/apps/git/state.ts +12 -0
  40. package/src/chat/assistant-message.ts +68 -19
  41. package/src/chat/tool-execution.ts +73 -30
  42. package/src/chat/user-message.ts +32 -19
  43. package/src/chrome/countdown-timer.ts +3 -3
  44. package/src/native/backend.ts +13 -5
  45. package/src/native/blobs.ts +34 -1
  46. package/src/native/reconcile.ts +24 -6
  47. package/src/overlays/advisor-config.ts +259 -8
  48. package/src/overlays/annotation-overlay.ts +420 -168
  49. package/src/overlays/ask-dialog.ts +154 -90
  50. package/src/overlays/btw-history-panel.ts +178 -124
  51. package/src/overlays/hook-editor.ts +90 -32
  52. package/src/overlays/hook-input.ts +105 -19
  53. package/src/overlays/jobs-panel.ts +154 -132
  54. package/src/overlays/login-dialog.ts +201 -91
  55. package/src/overlays/mcp-add-wizard.ts +559 -279
  56. package/src/overlays/model-browser.ts +23 -1
  57. package/src/overlays/model-hub.ts +2 -9
  58. package/src/overlays/pause-screen.ts +81 -23
  59. package/src/overlays/plugin-selector.ts +17 -1
  60. package/src/overlays/queue-mode-selector.ts +10 -0
  61. package/src/overlays/report-panel.ts +6 -2
  62. package/src/overlays/reset-usage-selector.ts +130 -21
  63. package/src/overlays/session-account-selector.ts +36 -0
  64. package/src/overlays/session-info-overlay.ts +197 -76
  65. package/src/overlays/show-images-selector.ts +1 -0
  66. package/src/overlays/theme-selector.ts +6 -1
  67. package/src/overlays/thinking-selector.ts +8 -4
  68. package/src/overlays/tree-selector.ts +117 -2
  69. package/src/overlays/usage-dashboard.ts +495 -143
  70. package/src/status-line/context-usage.ts +154 -57
  71. package/src/tools/bash.ts +9 -8
  72. package/src/tools/eval.ts +2 -2
  73. package/src/tools/grep.ts +1 -0
  74. package/src/tools/grouped-file-output.ts +11 -2
  75. package/src/tools/native-view.ts +2 -2
  76. package/src/tools/read.ts +12 -4
  77. package/src/tools/renderer.ts +2 -0
  78. package/src/tools/wait.ts +16 -30
@@ -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
  /**
@@ -828,10 +832,10 @@ export class AssistantMessageComponent extends Container {
828
832
  this.#nativeViewVersion++;
829
833
  }
830
834
 
831
- /** Mirrors a thinking section collapsed or expanded in the terminal. */
835
+ /** Mirrors a thinking section collapsed or expanded in the terminal; runs its controls' clicks. */
832
836
  handleNativeEvent(event: NativeUiEvent): void {
833
837
  if (event.type === "action") {
834
- this.#handleErrorAction(event.act);
838
+ this.#handleAction(event.act);
835
839
  return;
836
840
  }
837
841
  if (event.type !== "toggle" || !event.key.startsWith("k")) return;
@@ -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 }>();
@@ -860,6 +864,7 @@ export class AssistantMessageComponent extends Container {
860
864
  return entry.node;
861
865
  };
862
866
  const children: NativeChild[] = [];
867
+ let answered = false;
863
868
  if (message) {
864
869
  const live = !this.#transcriptBlockFinalized && this.#lastUpdateTransient;
865
870
  let tailIndex = -1;
@@ -876,6 +881,7 @@ export class AssistantMessageComponent extends Container {
876
881
  const content = message.content[index]!;
877
882
  const streaming = live && index === tailIndex;
878
883
  if (content.type === "text" && canonicalizeMessage(content.text)) {
884
+ answered = true;
879
885
  const source = content.text.trim();
880
886
  // The terminal renders this source itself and would resolve a relative
881
887
  // link against its own idea of the cwd: hand it the session-resolved
@@ -960,7 +966,17 @@ export class AssistantMessageComponent extends Container {
960
966
  }
961
967
  }
962
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
+ }
963
978
  for (const [toolCallId, images] of this.#toolImagesByCallId) {
979
+ if (media && this.#toolMediaPathByCallId.has(toolCallId)) continue;
964
980
  images.forEach((image, index) => {
965
981
  children.push(this.#nativeImages.get(`r${toolCallId}:${index}`, image.data, image.mimeType));
966
982
  });
@@ -983,16 +999,29 @@ export class AssistantMessageComponent extends Container {
983
999
  }
984
1000
  if (this.#turnUsage) {
985
1001
  const usage = formatTurnUsage(this.#turnUsage);
1002
+ const line: NativeChild[] = [];
1003
+ // Led by Copy and a dot: `⧉ · 12.4s · 19K tok this turn`.
1004
+ if (answered && hasTranscriptActions()) {
1005
+ line.push(
1006
+ // `copy-answer`, not Tern's local `copy` (that would copy the icon's empty label).
1007
+ node(
1008
+ "icon",
1009
+ {
1010
+ name: "copy",
1011
+ role: "omp.turn.copy",
1012
+ title: "Copy answer",
1013
+ aria: "Copy answer",
1014
+ actions: { click: "copy-answer" },
1015
+ },
1016
+ undefined,
1017
+ "copy",
1018
+ ),
1019
+ node("text", { text: "·" }, undefined, "dot"),
1020
+ );
1021
+ }
1022
+ line.push(node("text", { text: usage.text }, undefined, "text"));
986
1023
  children.push(
987
- node(
988
- "row",
989
- { role: "omp.turn.usage", gap: "xs", align: "center", title: usage.title },
990
- [
991
- node("icon", { name: "time" }, undefined, "icon"),
992
- node("text", { text: usage.text }, undefined, "text"),
993
- ],
994
- "turn-usage",
995
- ),
1024
+ node("row", { role: "omp.turn.usage", gap: "xs", align: "center", title: usage.title }, line, "turn-usage"),
996
1025
  );
997
1026
  }
998
1027
  return col(children, { role: this.#rewound ? "omp.assistant.rewound" : "omp.assistant" });
@@ -1081,11 +1110,24 @@ export class AssistantMessageComponent extends Container {
1081
1110
  return card({ role: "omp.error", tone: "error", key: "error" }, children);
1082
1111
  }
1083
1112
 
1084
- /** Error frame action clicks: omp's own retry, clipboard and model-picker paths. */
1085
- #handleErrorAction(act: string): void {
1113
+ /** Control clicks (the error frame's actions, the turn footer's Copy): omp's own retry, clipboard and model-picker paths. */
1114
+ #handleAction(act: string): void {
1086
1115
  if (act === "retry") runTranscriptAction({ act: "retry" });
1087
1116
  else if (act === "switch-model") runTranscriptAction({ act: "switch-model" });
1088
1117
  else if (act === "copy-error" && this.#errorText) runTranscriptAction({ act: "copy", text: this.#errorText });
1118
+ else if (act === "copy-answer") {
1119
+ const text = this.#answerText();
1120
+ if (text) runTranscriptAction({ act: "copy", text });
1121
+ }
1122
+ }
1123
+
1124
+ /** The answer as the transcript shows it: its text blocks' markdown, a blank line apart, without thinking or tool calls. */
1125
+ #answerText(): string {
1126
+ const blocks: string[] = [];
1127
+ for (const content of this.#displayedMessage?.content ?? []) {
1128
+ if (content.type === "text" && canonicalizeMessage(content.text)) blocks.push(content.text.trim());
1129
+ }
1130
+ return blocks.join("\n\n");
1089
1131
  }
1090
1132
 
1091
1133
  /** Width-independent stable identities for the streamed leading thinking run. */
@@ -1493,8 +1535,15 @@ export class AssistantMessageComponent extends Container {
1493
1535
  }
1494
1536
  }
1495
1537
 
1496
- 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 {
1497
1544
  if (!toolCallId) return;
1545
+ if (mediaPath) this.#toolMediaPathByCallId.set(toolCallId, mediaPath);
1546
+ else this.#toolMediaPathByCallId.delete(toolCallId);
1498
1547
  const validImages = images.filter(img => img.type === "image" && img.data && img.mimeType);
1499
1548
  if (validImages.length === 0) {
1500
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
  }
@@ -195,8 +195,9 @@ export class UserMessageComponent extends Container implements ReactionTarget, T
195
195
  /**
196
196
  * A head-less user-toned frame with the prompt as markdown under its
197
197
  * attached images (titled `#N` like their chips, opening the materialized
198
- * file on click): the fill says "you". A quiet toolbar (time, Copy, Rewind) fades in on hover when the
199
- * host handles transcript actions. The live-steering marker and the
198
+ * file on click): the fill says "you". A quiet footer like a turn's usage line
199
+ * (`⧉ ⏪ · 09:45`: Copy, Rewind, the time sent) fades in at its bottom-left on hover when
200
+ * the host handles transcript actions. The live-steering marker and the
200
201
  * agent's reaction are chips at the bottom-right, so a reaction landing
201
202
  * later updates the frame in place, even deep in scrollback.
202
203
  */
@@ -207,32 +208,44 @@ export class UserMessageComponent extends Container implements ReactionTarget, T
207
208
  this.#nativeHour12 = hour12;
208
209
  const children: NativeChild[] = [];
209
210
  if (!this.#synthetic && hasTranscriptActions()) {
210
- const tools: NativeChild[] = [];
211
+ const tools: NativeChild[] = [
212
+ // `copy-message`, not Tern's local `copy` (that would copy the icon's empty label).
213
+ node(
214
+ "icon",
215
+ {
216
+ name: "copy",
217
+ role: "omp.user.tool",
218
+ title: "Copy message",
219
+ aria: "Copy message",
220
+ actions: { click: "copy-message" },
221
+ },
222
+ undefined,
223
+ "copy",
224
+ ),
225
+ node(
226
+ "icon",
227
+ {
228
+ name: "rewind",
229
+ role: "omp.user.tool",
230
+ title: "Rewind the conversation to an earlier message",
231
+ aria: "Rewind",
232
+ actions: { click: "rewind" },
233
+ },
234
+ undefined,
235
+ "rewind",
236
+ ),
237
+ ];
211
238
  if (this.#timestamp !== undefined) {
212
239
  const at = new Date(this.#timestamp);
213
240
  tools.push(
241
+ node("text", { text: "·" }, undefined, "dot"),
214
242
  text([span(at.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12 }), "dim mono")], {
215
243
  role: "omp.user.time",
216
244
  title: at.toLocaleString([], { hour12 }),
217
245
  }),
218
246
  );
219
247
  }
220
- tools.push(
221
- // `copy-message`, not Tern's local `copy` (that would copy the label).
222
- text("Copy", {
223
- role: "omp.user.tool",
224
- actions: { click: "copy-message" },
225
- title: "Copy message",
226
- key: "copy",
227
- }),
228
- text("Rewind", {
229
- role: "omp.user.tool",
230
- actions: { click: "rewind" },
231
- title: "Rewind the conversation to an earlier message",
232
- key: "rewind",
233
- }),
234
- );
235
- children.push(node("row", { gap: "xs", role: "omp.user.tools" }, tools, "tools"));
248
+ children.push(node("row", { gap: "xs", align: "center", role: "omp.user.tools" }, tools, "tools"));
236
249
  }
237
250
  // Videos keep their chip only: the native image node decodes stills.
238
251
  const thumbs: NativeNode[] = [];
@@ -49,15 +49,15 @@ export class CountdownTimer {
49
49
 
50
50
  /**
51
51
  * The remaining time as a terminal-clocked `elapsed` node: its age is the
52
- * negative remaining time (the start lies in the future), so it counts up
53
- * toward zero and freezes there at expiry. The age is taken on the first
52
+ * negative remaining time (the start lies in the future), so it counts down
53
+ * to zero and holds there at expiry (no `stopped`: that would freeze it at
54
+ * zero from the start). The age is taken on the first
54
55
  * call after a (re)start, when the node is first written, and the node is
55
56
  * reused until {@link reset}.
56
57
  */
57
58
  describe(): NativeNode {
58
59
  this.#node ??= node("elapsed", {
59
60
  age: Math.min(0, Date.now() - this.#deadlineMs),
60
- stopped: 0,
61
61
  format: "short",
62
62
  });
63
63
  return this.#node;
@@ -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,