@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.
- package/CHANGELOG.md +42 -154
- package/THIRD-PARTY-NOTICES.txt +3 -3
- package/dist/types/app-keybindings.d.ts +8 -8
- package/dist/types/apps/git/diff-pane.d.ts +5 -0
- package/dist/types/apps/git/state.d.ts +11 -0
- package/dist/types/chat/assistant-message.d.ts +9 -4
- package/dist/types/chat/tool-execution.d.ts +32 -20
- package/dist/types/chat/user-message.d.ts +3 -2
- package/dist/types/chrome/countdown-timer.d.ts +3 -2
- package/dist/types/native/backend.d.ts +5 -4
- package/dist/types/native/blobs.d.ts +13 -1
- package/dist/types/overlays/annotation-overlay.d.ts +19 -0
- package/dist/types/overlays/ask-dialog.d.ts +9 -4
- package/dist/types/overlays/btw-history-panel.d.ts +2 -6
- package/dist/types/overlays/hook-editor.d.ts +7 -2
- package/dist/types/overlays/hook-input.d.ts +12 -4
- package/dist/types/overlays/jobs-panel.d.ts +8 -7
- package/dist/types/overlays/login-dialog.d.ts +8 -3
- package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
- package/dist/types/overlays/model-browser.d.ts +6 -1
- package/dist/types/overlays/pause-screen.d.ts +5 -0
- package/dist/types/overlays/report-panel.d.ts +5 -1
- package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
- package/dist/types/overlays/session-account-selector.d.ts +4 -0
- package/dist/types/overlays/session-info-overlay.d.ts +7 -3
- package/dist/types/overlays/usage-dashboard.d.ts +6 -6
- package/dist/types/status-line/context-usage.d.ts +1 -1
- package/dist/types/tools/bash.d.ts +2 -1
- package/dist/types/tools/eval.d.ts +2 -2
- package/dist/types/tools/grouped-file-output.d.ts +4 -1
- package/dist/types/tools/native-view.d.ts +1 -1
- package/dist/types/tools/read.d.ts +7 -1
- package/dist/types/tools/renderer.d.ts +2 -0
- package/package.json +9 -9
- package/src/app-keybindings.ts +8 -8
- package/src/apps/autoresearch-dashboard.ts +440 -27
- package/src/apps/git/diff-pane.ts +39 -35
- package/src/apps/git/git-tui.ts +53 -4
- package/src/apps/git/state.ts +12 -0
- package/src/chat/assistant-message.ts +68 -19
- package/src/chat/tool-execution.ts +73 -30
- package/src/chat/user-message.ts +32 -19
- package/src/chrome/countdown-timer.ts +3 -3
- package/src/native/backend.ts +13 -5
- package/src/native/blobs.ts +34 -1
- package/src/native/reconcile.ts +24 -6
- package/src/overlays/advisor-config.ts +259 -8
- package/src/overlays/annotation-overlay.ts +420 -168
- package/src/overlays/ask-dialog.ts +154 -90
- package/src/overlays/btw-history-panel.ts +178 -124
- package/src/overlays/hook-editor.ts +90 -32
- package/src/overlays/hook-input.ts +105 -19
- package/src/overlays/jobs-panel.ts +154 -132
- package/src/overlays/login-dialog.ts +201 -91
- package/src/overlays/mcp-add-wizard.ts +559 -279
- package/src/overlays/model-browser.ts +23 -1
- package/src/overlays/model-hub.ts +2 -9
- package/src/overlays/pause-screen.ts +81 -23
- package/src/overlays/plugin-selector.ts +17 -1
- package/src/overlays/queue-mode-selector.ts +10 -0
- package/src/overlays/report-panel.ts +6 -2
- package/src/overlays/reset-usage-selector.ts +130 -21
- package/src/overlays/session-account-selector.ts +36 -0
- package/src/overlays/session-info-overlay.ts +197 -76
- package/src/overlays/show-images-selector.ts +1 -0
- package/src/overlays/theme-selector.ts +6 -1
- package/src/overlays/thinking-selector.ts +8 -4
- package/src/overlays/tree-selector.ts +117 -2
- package/src/overlays/usage-dashboard.ts +495 -143
- package/src/status-line/context-usage.ts +154 -57
- package/src/tools/bash.ts +9 -8
- package/src/tools/eval.ts +2 -2
- package/src/tools/grep.ts +1 -0
- package/src/tools/grouped-file-output.ts +11 -2
- package/src/tools/native-view.ts +2 -2
- package/src/tools/read.ts +12 -4
- package/src/tools/renderer.ts +2 -0
- package/src/tools/wait.ts +16 -30
package/src/apps/git/git-tui.ts
CHANGED
|
@@ -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 =>
|
package/src/apps/git/state.ts
CHANGED
|
@@ -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.#
|
|
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
|
-
/**
|
|
1085
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1216
|
-
|
|
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
|
}
|
package/src/chat/user-message.ts
CHANGED
|
@@ -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
|
|
199
|
-
*
|
|
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
|
-
|
|
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
|
|
53
|
-
*
|
|
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;
|
package/src/native/backend.ts
CHANGED
|
@@ -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
|
|
208
|
-
* default APC limit and credits, the terminal's
|
|
209
|
-
* already detected, full motion, and `blobs`, so a
|
|
210
|
-
* are asked about rather than resent before the
|
|
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,
|