pi-mono/apps/p2/plugins/artifacts/tui.ts
David Brailovsky d6339fb097 feat(p2): TUI side panel; /artifacts opens artifacts beside the chat
- Runtime: ctx.panel shows one component right of the screen's body (about 40%,
  hidden below 100 columns, dock stays full width). F2 moves the focus into it
  (viewport and select keys scroll it, Escape returns), /close-panel closes it.
  Closes on conversation switch and facet reload, like overlays.
- Artifacts: Enter opens the artifact in the panel when the terminal fits it,
  else inline as before; the panel follows the artifact's newest version.
- Fix: /artifacts rows are keyed by name and version, so every version of an
  artifact can be selected.
2026-11-04 17:51:48 +01:00

290 lines
10 KiB
TypeScript

// 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
// 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 { BACKGROUND_CONTEXT, contribute, defineFacet, follow, statusOf, whenLive } from "@p2/app/v1";
import type { TuiContext } from "@p2/app/v1/tui";
import {
getTextOutput,
grantNotice,
ListOverlay,
type ListRow,
lineText,
record,
safeText,
str,
TextViewer,
type ToolRenderers,
type TuiCommand,
TuiCommands,
TuiToolRenderers,
} from "@p2/app/v1/tui";
import { ArtifactContent, type ArtifactInfo, ArtifactList } from "./contract.ts";
const NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
/** Collapsed cards show this many lines of a text or Markdown artifact; expanded ones all. */
const PREVIEW_LINES = 8;
const BROWSER_NOTE = "the browser needs its own sign-in to this server";
const message = (error: unknown) => (error instanceof Error ? error.message : String(error));
const artifactRenderers: ToolRenderers = {
renderCall(args, theme, context) {
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
const title = str(args.title) || str(args.name) || "...";
text.setText(`${theme.fg("toolTitle", theme.bold("artifact"))} ${theme.fg("accent", lineText(title))}`);
return text;
},
renderResult(result, options, theme, context) {
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
const details = record(result.details);
const saved =
!context.isError &&
typeof details?.title === "string" &&
typeof details.type === "string" &&
typeof details.version === "number";
if (!saved) {
text.setText(`\n${theme.fg(context.isError ? "error" : "toolOutput", getTextOutput(result))}`);
return text;
}
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).
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)`,
),
);
}
text.setText(`\n${lines.join("\n")}`);
return text;
},
};
/** Reads artifact versions of `session` through one held `ArtifactContent`, until released. */
function contentReader(ctx: TuiContext, session: string) {
const ref = ctx.host.hold(ArtifactContent, { session }, BACKGROUND_CONTEXT);
return {
read: async (artifact: ArtifactInfo) => {
await whenLive(ref);
if (statusOf(ref) !== "live" || ref.variant !== "reader") throw new Error("Artifacts are not available");
return ref.read({ name: artifact.name, version: artifact.version }, BACKGROUND_CONTEXT);
},
release: () => ref.release(),
};
}
type ContentReader = ReturnType<typeof contentReader>;
/**
* One version of an artifact as lines at a width (Markdown rendered, text and HTML/SVG source), cached per width;
* `load` reads a version and renders it once read.
*/
class ArtifactLines {
private content: string | undefined;
private status = "Loading…";
private cache: { width: number; lines: string[] } | undefined;
private readonly ctx: TuiContext;
private artifact: ArtifactInfo;
constructor(ctx: TuiContext, artifact: ArtifactInfo) {
this.ctx = ctx;
this.artifact = artifact;
}
get info(): ArtifactInfo {
return this.artifact;
}
/** Reads `artifact` (a version) and shows it once read; the previous version stays shown meanwhile. */
load(reader: ContentReader, artifact: ArtifactInfo): void {
void reader
.read(artifact)
.then((read) => {
// Reads may finish out of order: an older version never replaces a newer one.
if (artifact.version < this.artifact.version && this.content !== undefined) return;
this.artifact = artifact;
this.status = read.truncated ? "… truncated: Ctrl+S in /artifacts downloads all of it" : "";
this.content = read.content;
})
.catch((error: unknown) => {
this.status = message(error);
})
.finally(() => {
this.cache = undefined;
this.ctx.requestRender();
});
}
render(width: number): string[] {
const theme = this.ctx.theme.current;
if (this.content === undefined) return [theme.fg("muted", this.status)];
if (this.cache?.width === width) return this.cache.lines;
const body =
this.artifact.type === "markdown"
? new Markdown(safeText(this.content), 0, 0, this.ctx.theme.markdown).render(width)
: this.content.split("\n").map((line) => lineText(line));
const note =
this.artifact.type === "html" || this.artifact.type === "svg"
? [theme.fg("warning", `Source only: Ctrl+B in /artifacts opens it in the browser (${BROWSER_NOTE}).`), ""]
: [];
const status = this.status ? ["", theme.fg("warning", this.status)] : [];
this.cache = { width, lines: [...note, ...body, ...status] };
return this.cache.lines;
}
invalidate(): void {
this.cache = undefined;
}
}
/** An artifact shown inline, over the chat. */
function showArtifact(ctx: TuiContext, session: string, artifact: ArtifactInfo): void {
const shown = new ArtifactLines(ctx, artifact);
const viewer = ctx.overlay(
"artifact-view",
new TextViewer(ctx, {
title: `${artifact.title} · ${artifact.type.toUpperCase()} · version ${artifact.version}`,
lines: (width) => shown.render(width),
onClose: () => {
reader.release();
viewer.close();
},
}),
{ width: "100%", anchor: "bottom-center" },
);
const reader = contentReader(ctx, session);
shown.load(reader, artifact);
}
/** An artifact pinned in the side panel: it shows each new version as the conversation's artifact list reports it. */
function pinArtifact(ctx: TuiContext, session: string, conversation: number, artifact: ArtifactInfo): void {
const shown = new ArtifactLines(ctx, artifact);
const reader = contentReader(ctx, session);
const list = ctx.host.hold(ArtifactList, { session, conversation }, BACKGROUND_CONTEXT);
let loaded = artifact.version;
shown.load(reader, artifact);
const stop = follow(list.artifacts, (value) => {
// The list has one entry per version.
const latest = (value ?? [])
.filter((candidate) => candidate.name === artifact.name)
.reduce<ArtifactInfo | undefined>(
(max, candidate) => (max && max.version >= candidate.version ? max : candidate),
undefined,
);
if (!latest || latest.version <= loaded) return;
loaded = latest.version;
shown.load(reader, latest);
});
ctx.panel.open(
`artifact:${artifact.name}`,
{
render: (width) => shown.render(width),
invalidate: () => shown.invalidate(),
dispose: () => {
stop();
list.release();
reader.release();
},
},
() => `${lineText(shown.info.title)} · ${shown.info.type.toUpperCase()} · v${shown.info.version}`,
);
}
function openArtifacts(ctx: TuiContext): void {
const session = ctx.identity.session;
if (!session) return;
const list = ctx.host.hold(ArtifactList, { session, conversation: ctx.identity.conversation }, BACKGROUND_CONTEXT);
let artifacts: ArtifactInfo[] = [];
const stop = follow(list.artifacts, (value) => {
artifacts = (value ?? []).filter((artifact) => NAME.test(artifact.name));
ctx.requestRender();
});
const close = () => {
stop();
list.release();
overlay.close();
};
// One entry per version: a row is a name and a version.
const keyOf = (artifact: ArtifactInfo) => `${artifact.name}:${artifact.version}`;
const byRow = (row: ListRow | undefined) => artifacts.find((artifact) => keyOf(artifact) === row?.key);
const overlay = ctx.overlay(
"artifacts",
new ListOverlay(ctx, {
title: () => `Artifacts (${artifacts.length})`,
rows: () =>
artifacts.map((artifact) => ({
key: keyOf(artifact),
label: artifact.title,
detail: `${artifact.type.toUpperCase()} · v${artifact.version}`,
})),
onSelect: (row) => {
const artifact = byRow(row);
if (!artifact) return;
if (!ctx.panel.fits) return showArtifact(ctx, session, artifact);
close();
pinArtifact(ctx, session, ctx.identity.conversation, artifact);
},
actions: [
{
key: "ctrl+b",
label: "open in browser",
run: (row) => {
const artifact = byRow(row);
if (!artifact) return;
const query = new URLSearchParams({
session,
name: artifact.name,
version: String(artifact.version),
});
void ctx
.grant("openUrl", { kind: "origin", path: `/artifact?${query}` })
.then((result) => grantNotice(ctx, `Opened ${artifact.title} in the browser`, result));
},
},
{
key: "ctrl+s",
label: "download",
run: (row) => {
const artifact = byRow(row);
if (!artifact) return;
void ctx
.grant("download", { kind: "artifact", session, name: artifact.name, version: artifact.version })
.then((result) => grantNotice(ctx, `Downloaded ${artifact.title}`, result));
},
},
],
footer: () => [
ctx.theme.current.fg(
"dim",
` Enter shows it ${ctx.panel.fits ? "beside the chat" : "here"}; Ctrl+B opens it in the browser (${BROWSER_NOTE})`,
),
],
onClose: () => close(),
}),
{ width: "100%", anchor: "bottom-center" },
);
}
const COMMANDS: Record<string, TuiCommand> = {
"p2.artifacts": {
title: "Artifacts",
category: "Session",
when: "chat && !overlay",
slash: { name: "artifacts" },
run: (ctx) => openArtifacts(ctx),
},
};
export default defineFacet((host) =>
contribute(host, "p2.tui-artifacts", (self) => {
self.contribute(TuiToolRenderers, { artifact: artifactRenderers });
self.contribute(TuiCommands, COMMANDS);
}),
);