@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.
- package/CHANGELOG.md +19 -50
- 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 +8 -3
- package/dist/types/chat/tool-execution.d.ts +32 -20
- package/dist/types/native/backend.d.ts +5 -4
- package/dist/types/native/blobs.d.ts +13 -1
- package/dist/types/overlays/model-browser.d.ts +6 -1
- package/dist/types/tools/bash.d.ts +2 -1
- package/dist/types/tools/eval.d.ts +2 -2
- 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/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 +27 -6
- package/src/chat/tool-execution.ts +73 -30
- package/src/native/backend.ts +13 -5
- package/src/native/blobs.ts +34 -1
- package/src/overlays/model-browser.ts +23 -1
- package/src/overlays/model-hub.ts +2 -9
- package/src/overlays/tree-selector.ts +117 -2
- package/src/tools/bash.ts +9 -8
- package/src/tools/eval.ts +2 -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
|
/**
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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/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,
|
package/src/native/blobs.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
3454
|
+
const mark = providerMark(providerId);
|
|
3462
3455
|
if (entry.locked) {
|
|
3463
3456
|
const envVars = providerEntry(providerId)?.envVars ?? [];
|
|
3464
3457
|
scopes.push({
|