mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(p2): render artifacts inline at the tool call site
The artifact tool takes inline: true to also show the produced version below its transcript card: Markdown/text rendered, HTML/SVG in the sandboxed preview frame. Drawings fill the frame's width (an SVG with a viewBox or an absolute size scales to it; an HTML page that is only an SVG or image too) and the frame takes the content height the preview reports, capped at 70vh. Every viewer sees it, guests included (owner decision). The TUI renders inline Markdown and text. SVG previews now carry the diagnostics bridge in CDATA (its && broke the XML).
This commit is contained in:
parent
9e870f6f00
commit
04f672a18d
15 changed files with 679 additions and 100 deletions
|
|
@ -1,21 +1,90 @@
|
|||
import { artifactUrls, Markdown, ViewerShell } from "@p2/app/v1/web";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { type Ref, useEffect, useRef, useState } from "react";
|
||||
import type { ArtifactInfo } from "./contract.ts";
|
||||
|
||||
type Diagnostic = { level: "ready" | "error" | "warn" | "csp" | "webgl"; message: string };
|
||||
/** What a preview frame posts on the diagnostic channel: a diagnostic, or its content height (`store.ts`). */
|
||||
type FrameMessage = Diagnostic | { level: "size"; height: number };
|
||||
|
||||
function diagnostic(value: unknown): Diagnostic | undefined {
|
||||
/** A message from a preview frame (untrusted: the artifact's own script can post anything). */
|
||||
function frameMessage(value: unknown): FrameMessage | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return;
|
||||
const data = value as Record<string, unknown>;
|
||||
if (
|
||||
data.channel !== "p2-artifact-diagnostic" ||
|
||||
!["ready", "error", "warn", "csp", "webgl"].includes(String(data.level)) ||
|
||||
typeof data.message !== "string"
|
||||
)
|
||||
if (data.channel !== "p2-artifact-diagnostic") return;
|
||||
if (data.level === "size")
|
||||
return typeof data.height === "number" && Number.isFinite(data.height) && data.height >= 0
|
||||
? { level: "size", height: data.height }
|
||||
: undefined;
|
||||
if (!["ready", "error", "warn", "csp", "webgl"].includes(String(data.level)) || typeof data.message !== "string")
|
||||
return;
|
||||
return { level: data.level as Diagnostic["level"], message: data.message.slice(0, 4000) };
|
||||
}
|
||||
|
||||
/** Calls `receive` with each message the frame posts on the diagnostic channel. */
|
||||
function useFrameMessages(
|
||||
frame: { readonly current: HTMLIFrameElement | null },
|
||||
receive: (message: FrameMessage) => void,
|
||||
): void {
|
||||
const latest = useRef(receive);
|
||||
latest.current = receive;
|
||||
useEffect(() => {
|
||||
const listener = (event: MessageEvent) => {
|
||||
if (event.source !== frame.current?.contentWindow) return;
|
||||
const message = frameMessage(event.data);
|
||||
if (message) latest.current(message);
|
||||
};
|
||||
window.addEventListener("message", listener);
|
||||
return () => window.removeEventListener("message", listener);
|
||||
}, [frame]);
|
||||
}
|
||||
|
||||
/** A text or Markdown artifact's content from its route: undefined while it loads, or the error. */
|
||||
function useArtifactText(url: string | undefined): { content?: string; error?: string } {
|
||||
const [state, setState] = useState<{ url?: string; content?: string; error?: string }>({});
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
const abort = new AbortController();
|
||||
void fetch(url, { credentials: "same-origin", cache: "no-store", signal: abort.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`Could not open artifact (${response.status})`);
|
||||
const content = await response.text();
|
||||
setState({ url, content });
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!abort.signal.aborted)
|
||||
setState({ url, error: caught instanceof Error ? caught.message : "Could not open artifact" });
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [url]);
|
||||
return state.url === url ? state : {};
|
||||
}
|
||||
|
||||
/** The sandboxed frame of an HTML or SVG preview: an opaque origin, and the route's CSP sandbox on top. */
|
||||
function ArtifactFrame({
|
||||
url,
|
||||
title,
|
||||
frame,
|
||||
height,
|
||||
}: {
|
||||
url: string;
|
||||
title: string;
|
||||
frame: Ref<HTMLIFrameElement>;
|
||||
height?: number;
|
||||
}) {
|
||||
return (
|
||||
<iframe
|
||||
// A new frame per artifact: changing a frame's src would add a history entry.
|
||||
key={url}
|
||||
ref={frame}
|
||||
title={title}
|
||||
src={url}
|
||||
sandbox="allow-scripts allow-modals allow-pointer-lock"
|
||||
referrerPolicy="no-referrer"
|
||||
style={height === undefined ? undefined : { height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactPanel({
|
||||
sessionId,
|
||||
artifact,
|
||||
|
|
@ -28,8 +97,7 @@ export function ArtifactPanel({
|
|||
const { view: url, download: downloadUrl, filename } = artifactUrls(sessionId, artifact);
|
||||
const document = artifact.type === "markdown" || artifact.type === "text";
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
const [content, setContent] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
const { content, error } = useArtifactText(document ? url : undefined);
|
||||
const [diagnostics, setDiagnostics] = useState<Diagnostic[]>([]);
|
||||
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||
|
||||
|
|
@ -39,35 +107,11 @@ export function ArtifactPanel({
|
|||
setDiagnosticsOpen(false);
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
if (document) {
|
||||
setContent(undefined);
|
||||
setError(undefined);
|
||||
void fetch(url, { credentials: "same-origin", cache: "no-store", signal: abort.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`Could not open artifact (${response.status})`);
|
||||
setContent(await response.text());
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (!abort.signal.aborted)
|
||||
setError(caught instanceof Error ? caught.message : "Could not open artifact");
|
||||
});
|
||||
}
|
||||
return () => abort.abort();
|
||||
}, [document, url]);
|
||||
|
||||
useEffect(() => {
|
||||
const receive = (event: MessageEvent) => {
|
||||
if (event.source !== frame.current?.contentWindow) return;
|
||||
const next = diagnostic(event.data);
|
||||
if (!next) return;
|
||||
setDiagnostics((current) => [...current.slice(-49), next]);
|
||||
if (next.level !== "ready") setDiagnosticsOpen(true);
|
||||
};
|
||||
window.addEventListener("message", receive);
|
||||
return () => window.removeEventListener("message", receive);
|
||||
}, []);
|
||||
useFrameMessages(frame, (next) => {
|
||||
if (next.level === "size") return;
|
||||
setDiagnostics((current) => [...current.slice(-49), next]);
|
||||
if (next.level !== "ready") setDiagnosticsOpen(true);
|
||||
});
|
||||
|
||||
const problems = diagnostics.filter((entry) => entry.level !== "ready").length;
|
||||
return (
|
||||
|
|
@ -122,16 +166,72 @@ export function ArtifactPanel({
|
|||
<pre>{content}</pre>
|
||||
)
|
||||
) : (
|
||||
<iframe
|
||||
// A new frame per artifact: changing a frame's src would add a history entry.
|
||||
key={url}
|
||||
ref={frame}
|
||||
title={artifact.title}
|
||||
src={url}
|
||||
sandbox="allow-scripts allow-modals allow-pointer-lock"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<ArtifactFrame url={url} title={artifact.title} frame={frame} />
|
||||
)}
|
||||
</ViewerShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The heights inline frames last reported, by URL (versions are immutable), so a row the transcript mounts again
|
||||
* keeps its size instead of starting at `FRAME_HEIGHT`.
|
||||
*/
|
||||
const frameHeights = new Map<string, number>();
|
||||
const FRAME_HEIGHTS = 256;
|
||||
/** A frame's height before it reports one. */
|
||||
const FRAME_HEIGHT = 150;
|
||||
|
||||
/**
|
||||
* An artifact version rendered at its tool call site, below the card (`inline: true`): Markdown and text as in the
|
||||
* viewer; HTML and SVG in the viewer's sandboxed frame, sized to the content height it reports after drawings were
|
||||
* scaled to its width (`store.ts`). CSS caps it at about 70vh: taller HTML scrolls inside the frame, an SVG scales
|
||||
* down to fit. Rendered for every viewer of the session (owner decision).
|
||||
*/
|
||||
export function InlineArtifact({ sessionId, artifact }: { sessionId: string; artifact: ArtifactInfo }) {
|
||||
const url = artifactUrls(sessionId, artifact).view;
|
||||
const document = artifact.type === "markdown" || artifact.type === "text";
|
||||
const text = useArtifactText(document ? url : undefined);
|
||||
const [available, setAvailable] = useState<{ url: string; ok: boolean }>();
|
||||
const frame = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(() => frameHeights.get(url));
|
||||
|
||||
// The frame would show the route's bare 404 text: check the version first.
|
||||
useEffect(() => {
|
||||
if (document) return;
|
||||
const abort = new AbortController();
|
||||
void fetch(url, { method: "HEAD", credentials: "same-origin", cache: "no-store", signal: abort.signal })
|
||||
.then((response) => setAvailable({ url, ok: response.ok }))
|
||||
.catch(() => {
|
||||
if (!abort.signal.aborted) setAvailable({ url, ok: false });
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [document, url]);
|
||||
|
||||
useFrameMessages(frame, (message) => {
|
||||
if (message.level !== "size") return;
|
||||
const next = Math.min(Math.ceil(message.height), 100_000);
|
||||
frameHeights.delete(url);
|
||||
frameHeights.set(url, next);
|
||||
if (frameHeights.size > FRAME_HEIGHTS) frameHeights.delete(frameHeights.keys().next().value ?? "");
|
||||
setHeight(next);
|
||||
});
|
||||
|
||||
const unavailable = document ? text.error !== undefined : available?.url === url && !available.ok;
|
||||
return (
|
||||
<div className={`inline-artifact ${artifact.type}`} data-artifact={`${artifact.name}:${artifact.version}`}>
|
||||
{unavailable ? (
|
||||
<div className="notice error">Artifact unavailable</div>
|
||||
) : document ? (
|
||||
text.content === undefined ? (
|
||||
<div className="spinner" />
|
||||
) : artifact.type === "markdown" ? (
|
||||
<Markdown text={text.content} />
|
||||
) : (
|
||||
<pre>{text.content}</pre>
|
||||
)
|
||||
) : (
|
||||
<ArtifactFrame url={url} title={artifact.title} frame={frame} height={height ?? FRAME_HEIGHT} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,20 +14,30 @@ export type ArtifactType = (typeof TYPES)[number];
|
|||
export type ArtifactInfo = { name: string; title: string; type: ArtifactType; version: number };
|
||||
export type ArtifactInput = { name: string; title: string; type: ArtifactType; content: string };
|
||||
|
||||
const DIAGNOSTIC_BOOTSTRAP = `<script>(()=>{const channel="p2-artifact-diagnostic";const send=(level,args)=>{try{parent.postMessage({channel,level,message:args.map(value=>{try{return typeof value==="string"?value:JSON.stringify(value)}catch{return String(value)}}).join(" ").slice(0,4000)},"*")}catch{}};for(const level of ["error","warn"]){const original=console[level];console[level]=(...args)=>{send(level,args);original.apply(console,args)}}addEventListener("error",event=>send("error",[event.message,event.filename&&(event.filename+":"+event.lineno+":"+event.colno)].filter(Boolean)));addEventListener("unhandledrejection",event=>send("error",[event.reason?.stack||event.reason||"Unhandled promise rejection"]));addEventListener("securitypolicyviolation",event=>send("csp",[event.violatedDirective,event.blockedURI||"blocked"]));addEventListener("webglcontextlost",event=>send("webgl",["WebGL context lost"]),true);send("ready",["Preview diagnostics connected"]);})();</script>`;
|
||||
// The preview's diagnostics bridge: console errors and warnings, uncaught errors, CSP violations and lost WebGL
|
||||
// contexts, plus the document's content height (level "size", for inline previews sized to their content). Drawings fill
|
||||
// the frame's width: an SVG document with a viewBox (or an absolute width and height, made its viewBox) is set to
|
||||
// 100% wide and high, so it scales with its aspect and reports viewBox height at its width; an HTML document whose
|
||||
// body is just a viewBox SVG or an image gets them 100% wide at height auto (zero specificity: any author rule wins).
|
||||
// HTML is measured without its scrollbar: a frame still too short shows one, and the narrower page would report a
|
||||
// taller height, which keeps the scrollbar once the frame grows to it.
|
||||
const DIAGNOSTIC_SCRIPT = `(()=>{const channel="p2-artifact-diagnostic";const send=(level,args)=>{try{parent.postMessage({channel,level,message:args.map(value=>{try{return typeof value==="string"?value:JSON.stringify(value)}catch{return String(value)}}).join(" ").slice(0,4000)},"*")}catch{}};for(const level of ["error","warn"]){const original=console[level];console[level]=(...args)=>{send(level,args);original.apply(console,args)}}addEventListener("error",event=>send("error",[event.message,event.filename&&(event.filename+":"+event.lineno+":"+event.colno)].filter(Boolean)));addEventListener("unhandledrejection",event=>send("error",[event.reason?.stack||event.reason||"Unhandled promise rejection"]));addEventListener("securitypolicyviolation",event=>send("csp",[event.violatedDirective,event.blockedURI||"blocked"]));addEventListener("webglcontextlost",event=>send("webgl",["WebGL context lost"]),true);send("ready",["Preview diagnostics connected"]);const fit=()=>{const root=document.documentElement;if(root instanceof SVGSVGElement){const width=root.width.baseVal,height=root.height.baseVal,absolute=length=>length.unitType!==SVGLength.SVG_LENGTHTYPE_PERCENTAGE&&length.value>0;if(!root.hasAttribute("viewBox")&&absolute(width)&&absolute(height))root.setAttribute("viewBox","0 0 "+width.value+" "+height.value);const box=root.viewBox.baseVal;if(box&&box.width>0&&box.height>0){root.setAttribute("width","100%");root.setAttribute("height","100%")}}else if(root){const style=document.createElement("style");style.textContent=":where(body>svg[viewBox]:only-child,body>img:only-child){display:block;width:100%;height:auto}";(document.head||root).prepend(style)}};try{fit()}catch{}let reported=0;const measure=()=>{const root=document.documentElement;if(!root)return;let height;if(root instanceof SVGSVGElement){const box=root.viewBox.baseVal;height=root.height.baseVal.unitType===SVGLength.SVG_LENGTHTYPE_PERCENTAGE&&box&&box.width>0&&box.height>0?root.getBoundingClientRect().width*box.height/box.width:root.getBoundingClientRect().height}else{const overflow=root.style.overflow;root.style.overflow="hidden";height=Math.max(root.getBoundingClientRect().height,document.body?document.body.scrollHeight:0);root.style.overflow=overflow}height=Math.ceil(height);if(height===reported)return;reported=height;try{parent.postMessage({channel,level:"size",message:"",height},"*")}catch{}};const observe=()=>{try{const observer=new ResizeObserver(measure);observer.observe(document.documentElement);if(document.body)observer.observe(document.body)}catch{}measure()};addEventListener("load",measure);addEventListener("resize",measure);if(document.readyState==="loading")addEventListener("DOMContentLoaded",observe);else observe();})();`;
|
||||
|
||||
/** Add a diagnostics bridge without changing the immutable stored source. */
|
||||
export function instrumentArtifact(info: ArtifactInfo, content: string): string {
|
||||
if (info.type === "html") {
|
||||
const head = /<head(?:\s[^>]*)?>/i.exec(content);
|
||||
const script = `<script>${DIAGNOSTIC_SCRIPT}</script>`;
|
||||
if (head)
|
||||
return `${content.slice(0, head.index + head[0].length)}${DIAGNOSTIC_BOOTSTRAP}${content.slice(head.index + head[0].length)}`;
|
||||
return `${DIAGNOSTIC_BOOTSTRAP}${content}`;
|
||||
return `${content.slice(0, head.index + head[0].length)}${script}${content.slice(head.index + head[0].length)}`;
|
||||
return `${script}${content}`;
|
||||
}
|
||||
if (info.type === "svg") {
|
||||
const root = /<svg(?:\s[^>]*)?>/i.exec(content);
|
||||
if (root)
|
||||
return `${content.slice(0, root.index + root[0].length)}${DIAGNOSTIC_BOOTSTRAP}${content.slice(root.index + root[0].length)}`;
|
||||
// SVG is XML: the script's `&&` would end the document without CDATA. An empty root (`<svg/>`) has no inside.
|
||||
const script = `<script><![CDATA[${DIAGNOSTIC_SCRIPT}]]></script>`;
|
||||
if (root && !root[0].endsWith("/>"))
|
||||
return `${content.slice(0, root.index + root[0].length)}${script}${content.slice(root.index + root[0].length)}`;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { defineDoc, defineExtension, defineTool } from "@earendil-works/pi-durable";
|
||||
import { obj, str, union } from "@earendil-works/pi-schema";
|
||||
import { bool, obj, opt, str, union } from "@earendil-works/pi-schema";
|
||||
import { literal } from "@p2/app/v1";
|
||||
import { agentConversation } from "@p2/app/v1/worker";
|
||||
import { type ArtifactInfo, Artifacts } from "./store.ts";
|
||||
|
||||
type ArtifactState = { artifacts: ArtifactInfo[] };
|
||||
|
||||
/**
|
||||
* The tool's result details: the saved version, and `inline: true` when the call asked to show it at the call site
|
||||
* too (the transcripts render that version below the card). The index (`ArtifactDoc`) keeps only `ArtifactInfo`.
|
||||
*/
|
||||
export type ArtifactDetails = ArtifactInfo & { inline?: true };
|
||||
|
||||
/**
|
||||
* Complete artifact index of one agent (on conversation 1 for the main agent, see `agentConversation`); reset and
|
||||
* compaction never rewind it.
|
||||
|
|
@ -30,16 +36,22 @@ export function createArtifactExtension(dataDirectory: string, sessionId: string
|
|||
defineTool({
|
||||
name: "artifact",
|
||||
description:
|
||||
"Create or update a session artifact (HTML, SVG, Markdown or text; maximum 2 MiB UTF-8). Reuse its stable lowercase slug name to publish a new immutable numbered version. Versions persist across restarts. All authorized viewers of this session may explicitly click to open an isolated preview; previews never open automatically. Do not publish secrets. HTML may use HTTPS CDN resources, but has no app identity or parent privileges.",
|
||||
"Create or update a session artifact (HTML, SVG, Markdown or text; maximum 2 MiB UTF-8). Reuse its stable lowercase slug name to publish a new immutable numbered version. Versions persist across restarts. Viewers open an isolated preview from the card in the transcript. Set inline: true to also show this version right below the card, in the chat, for every viewer of the session: use it for diagrams (prefer SVG, e.g. sequence or architecture diagrams, over ASCII art), charts and other small visual results the reader should see without opening anything; keep inline content compact (it is shown at most about 70% of the screen high, then scrolls) and leave large documents and apps as plain artifacts. Do not publish secrets. HTML and SVG run in a sandbox with no app identity or parent privileges; HTML may use HTTPS CDN resources.",
|
||||
parameters: obj({
|
||||
name: str({ pattern: "^[a-z0-9][a-z0-9_-]{0,63}$", minLength: 1, maxLength: 64 }),
|
||||
title: str({ minLength: 1, maxLength: 200 }),
|
||||
type: union([literal("html"), literal("svg"), literal("markdown"), literal("text")]),
|
||||
content: str(),
|
||||
inline: opt(
|
||||
bool({
|
||||
description:
|
||||
"Also render it inline at the tool call site in the transcript (default false). For diagrams and small visual results.",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
replay: "unsafe",
|
||||
execute: async (args, api, context) => {
|
||||
const info = await artifacts.save(sessionId, args);
|
||||
execute: async ({ inline, ...input }, api, context) => {
|
||||
const info = await artifacts.save(sessionId, input);
|
||||
// The main agent's list is one, whichever `/tree` branch it works in.
|
||||
const holder = await agentConversation(api, api.conversationId, context);
|
||||
await api.commit(async (tx) => {
|
||||
|
|
@ -55,10 +67,10 @@ export function createArtifactExtension(dataDirectory: string, sessionId: string
|
|||
output: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Artifact ready: ${info.title} (${info.name}, version ${info.version}).`,
|
||||
text: `Artifact ready: ${info.title} (${info.name}, version ${info.version}).${inline ? " Shown inline in the transcript." : ""}`,
|
||||
},
|
||||
],
|
||||
details: info,
|
||||
details: (inline ? { ...info, inline: true } : info) satisfies ArtifactDetails,
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// Artifacts in the TUI (p2-tui-port.md P11c), after the web's artifact card, picker and viewer. The card shows the
|
||||
// title, type and version; expanded, a text or Markdown artifact shows the content the call wrote. `/artifacts` lists
|
||||
// title, type and version; expanded, a text or Markdown artifact shows the content the call wrote. A call with
|
||||
// `inline: true` shows all of it at once (Markdown rendered); inline HTML and SVG keep the card line. `/artifacts` lists
|
||||
// the conversation's artifacts (`ArtifactList`): Enter shows one inline (`ArtifactContent`; Markdown rendered, HTML and
|
||||
// SVG as source; in the side panel, where it follows new versions, when the terminal is wide enough), Ctrl+B opens it in the browser (launcher `openUrl`; the browser needs its own sign-in) and Ctrl+S
|
||||
// downloads it (launcher `download`). The worker never fetches the `/artifact` route itself: it has no cookie.
|
||||
|
||||
import { Markdown, Text } from "@earendil-works/pi-tui";
|
||||
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
||||
import { BACKGROUND_CONTEXT, contribute, defineFacet, follow, statusOf, whenLive } from "@p2/app/v1";
|
||||
import type { TuiContext } from "@p2/app/v1/tui";
|
||||
import {
|
||||
|
|
@ -39,7 +40,9 @@ const artifactRenderers: ToolRenderers = {
|
|||
return text;
|
||||
},
|
||||
renderResult(result, options, theme, context) {
|
||||
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
||||
const component = context.lastComponent instanceof Container ? context.lastComponent : new Container();
|
||||
component.clear();
|
||||
component.addChild(new Spacer(1));
|
||||
const details = record(result.details);
|
||||
const saved =
|
||||
!context.isError &&
|
||||
|
|
@ -47,28 +50,35 @@ const artifactRenderers: ToolRenderers = {
|
|||
typeof details.type === "string" &&
|
||||
typeof details.version === "number";
|
||||
if (!saved) {
|
||||
text.setText(`\n${theme.fg(context.isError ? "error" : "toolOutput", getTextOutput(result))}`);
|
||||
return text;
|
||||
component.addChild(new Text(theme.fg(context.isError ? "error" : "toolOutput", getTextOutput(result)), 0, 0));
|
||||
return component;
|
||||
}
|
||||
const lines = [
|
||||
`${theme.fg("success", "■")} ${lineText(String(details.title))} ${theme.fg("muted", `· ${lineText(String(details.type)).toUpperCase()} · version ${details.version}`)} ${theme.fg("dim", "· /artifacts opens")}`,
|
||||
];
|
||||
// What the call wrote (text and Markdown; HTML and SVG run only in a browser).
|
||||
component.addChild(
|
||||
new Text(
|
||||
`${theme.fg("success", "■")} ${lineText(String(details.title))} ${theme.fg("muted", `· ${lineText(String(details.type)).toUpperCase()} · version ${details.version}`)} ${theme.fg("dim", "· /artifacts opens")}`,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
// What the call wrote (text and Markdown; HTML and SVG run only in a browser): the version this call produced.
|
||||
const content = str(context.args.content);
|
||||
if (content && (details.type === "text" || details.type === "markdown")) {
|
||||
const all = content.split("\n").map((line) => theme.fg("toolOutput", lineText(line)));
|
||||
const shown = options.expanded ? all : all.slice(0, PREVIEW_LINES);
|
||||
lines.push("", ...shown);
|
||||
if (shown.length < all.length)
|
||||
lines.push(
|
||||
theme.fg(
|
||||
"muted",
|
||||
`... (${all.length - shown.length} more lines, ${context.ui.keyText("app.tools.expand")} to expand)`,
|
||||
),
|
||||
);
|
||||
if (!content || (details.type !== "text" && details.type !== "markdown")) return component;
|
||||
component.addChild(new Spacer(1));
|
||||
if (details.inline === true && details.type === "markdown") {
|
||||
component.addChild(new Markdown(safeText(content), 0, 0, context.ui.theme.markdown));
|
||||
return component;
|
||||
}
|
||||
text.setText(`\n${lines.join("\n")}`);
|
||||
return text;
|
||||
const all = content.split("\n").map((line) => theme.fg("toolOutput", lineText(line)));
|
||||
const shown = options.expanded || details.inline === true ? all : all.slice(0, PREVIEW_LINES);
|
||||
if (shown.length < all.length)
|
||||
shown.push(
|
||||
theme.fg(
|
||||
"muted",
|
||||
`... (${all.length - shown.length} more lines, ${context.ui.keyText("app.tools.expand")} to expand)`,
|
||||
),
|
||||
);
|
||||
component.addChild(new Text(shown.join("\n"), 0, 0));
|
||||
return component;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
ArtifactPicker,
|
||||
closeLayers,
|
||||
FilePanel,
|
||||
InlineArtifactView,
|
||||
OpenViewer,
|
||||
openLayer,
|
||||
SessionId,
|
||||
|
|
@ -17,7 +18,7 @@ import {
|
|||
import type { ReactNode } from "react";
|
||||
import { DownloadInfoSchema } from "../files/contract.ts";
|
||||
import { ArtifactInfoSchema, ArtifactList } from "./contract.ts";
|
||||
import { ArtifactPanel } from "./preview.tsx";
|
||||
import { ArtifactPanel, InlineArtifact } from "./preview.tsx";
|
||||
|
||||
const ViewerSchema = union([
|
||||
obj({ kind: literal("file"), file: DownloadInfoSchema }, { open: true }),
|
||||
|
|
@ -51,7 +52,7 @@ function Content({ children }: { children?: ReactNode }) {
|
|||
return (
|
||||
<SessionId.Provider value={route.session}>
|
||||
<OpenViewer.Provider value={open}>
|
||||
{children}
|
||||
<InlineArtifactView.Provider value={InlineArtifact}>{children}</InlineArtifactView.Provider>
|
||||
{top?.kind === "sheet" && top.name === "artifacts" && (
|
||||
<ArtifactPicker
|
||||
artifacts={artifacts.toReversed()}
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ export function createFakeModels(options: CreateModelsOptions = {}) {
|
|||
? fauxToolCall("subagent", JSON.parse(text.slice("subagent ".length)))
|
||||
: text.startsWith("team ")
|
||||
? fauxToolCall("team", JSON.parse(text.slice("team ".length)))
|
||||
: /^(history_(search|read|handoff)|location_(envs|dirs|move)|tool_search|mcp__\w+) /.test(
|
||||
: /^(artifact|history_(search|read|handoff)|location_(envs|dirs|move)|tool_search|mcp__\w+) /.test(
|
||||
text,
|
||||
)
|
||||
? fauxToolCall(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue