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.
This commit is contained in:
David Brailovsky 2026-11-04 17:51:03 +01:00
parent edc9c69892
commit d6339fb097
7 changed files with 468 additions and 65 deletions

View file

@ -245,6 +245,15 @@ pi's defaults (rebindable in pi's `keybindings.json` or p2 prefs; `/hotkeys` lis
| Ctrl+T / Ctrl+O | Show thinking / expand tool output |
| PageUp / PageDown / Ctrl+Home / Ctrl+End | Scroll the transcript (pi-tui's alt-screen keys; Ctrl+End follows again) |
| F1 / `/hotkeys` / `/reload` | Command palette / shortcuts / fetch the server TUI bundle into a fresh worker |
| F2 / `/close-panel` | Move the focus into the side panel and back (Escape also returns) / close it |
**Side panel.** `ctx.panel.open(id, content, title)` shows one component to the right of the screen's body (about 40%
of the width, behind a bar; the dock stays full width), in a `ScrollView` the runtime owns. Plugins open it (Enter in
`/artifacts` shows an artifact there when the terminal fits it; it follows new versions through `ArtifactList`). The editor keeps
the focus; F2 (`p2.panel.focus`) moves it into the panel, where the runtime sets pi-tui's `capturesViewportInput` and
scrolls the panel with the viewport and select keys, keeps other keys from the editor and returns the focus on Escape.
Search stays on the transcript (the primary scroll view). Hidden below 100 columns; closed on navigation to another
conversation and whenever the command registry changes (a facet reload), like overlays.
The launcher polls the bundle version every second. A changed hash prepares a new worker while the old one remains
active; once ready, it copies bounded UI state (selection, drafts, send retry IDs, display toggles, reading position),

View file

@ -1,7 +1,7 @@
// 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), Ctrl+B opens it in the browser (launcher `openUrl`; the browser needs its own sign-in) and Ctrl+S
// 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";
@ -72,58 +72,129 @@ const artifactRenderers: ToolRenderers = {
},
};
/** An artifact shown inline: Markdown rendered, text and HTML/SVG source as lines. */
function showArtifact(ctx: TuiContext, session: string, artifact: ArtifactInfo): void {
let content: string | undefined;
let status = "Loading…";
let cache: { width: number; lines: string[] } | undefined;
const lines = (width: number): string[] => {
if (content === undefined) return [ctx.theme.current.fg("muted", status)];
if (cache?.width === width) return cache.lines;
const body =
artifact.type === "markdown"
? new Markdown(safeText(content), 0, 0, ctx.theme.markdown).render(width)
: content.split("\n").map((line) => lineText(line));
const note =
artifact.type === "html" || artifact.type === "svg"
? [
ctx.theme.current.fg(
"warning",
`Source only: Ctrl+B in /artifacts opens it in the browser (${BROWSER_NOTE}).`,
),
"",
]
: [];
cache = { width, lines: [...note, ...body, ...(status ? ["", ctx.theme.current.fg("warning", status)] : [])] };
return cache.lines;
/** 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,
lines: (width) => shown.render(width),
onClose: () => {
ref.release();
reader.release();
viewer.close();
},
}),
{ width: "100%", anchor: "bottom-center" },
);
const ref = ctx.host.hold(ArtifactContent, { session }, BACKGROUND_CONTEXT);
void whenLive(ref)
.then(async () => {
if (statusOf(ref) !== "live" || ref.variant !== "reader") throw new Error("Artifacts are not available");
const read = await ref.read({ name: artifact.name, version: artifact.version }, BACKGROUND_CONTEXT);
status = read.truncated ? "… truncated: Ctrl+S in /artifacts downloads all of it" : "";
content = read.content;
})
.catch((error: unknown) => {
status = message(error);
})
.finally(() => {
cache = undefined;
ctx.requestRender();
});
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 {
@ -135,20 +206,30 @@ function openArtifacts(ctx: TuiContext): void {
artifacts = (value ?? []).filter((artifact) => NAME.test(artifact.name));
ctx.requestRender();
});
const byRow = (row: ListRow | undefined) => artifacts.find((artifact) => artifact.name === row?.key);
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: artifact.name,
key: keyOf(artifact),
label: artifact.title,
detail: `${artifact.type.toUpperCase()} · v${artifact.version}`,
})),
onSelect: (row) => {
const artifact = byRow(row);
if (artifact) showArtifact(ctx, session, artifact);
if (!artifact) return;
if (!ctx.panel.fits) return showArtifact(ctx, session, artifact);
close();
pinArtifact(ctx, session, ctx.identity.conversation, artifact);
},
actions: [
{
@ -180,13 +261,12 @@ function openArtifacts(ctx: TuiContext): void {
},
],
footer: () => [
ctx.theme.current.fg("dim", ` Enter shows it here; Ctrl+B opens it in the browser (${BROWSER_NOTE})`),
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: () => {
stop();
list.release();
overlay.close();
},
onClose: () => close(),
}),
{ width: "100%", anchor: "bottom-center" },
);

View file

@ -41,6 +41,30 @@ export interface TuiOverlayHandle {
readonly closed: boolean;
}
export interface TuiPanelHandle {
readonly id: string;
close(): void;
readonly closed: boolean;
}
/** The side panel next to the screen: one at a time, hidden on narrow terminals. */
export interface TuiPanel {
/**
* Shows `content` in the panel under `title`, replacing what it showed (that content is disposed). The editor keeps
* the focus; `p2.panel.focus` moves it into the panel. Closed on navigation to another conversation and on reload.
*/
open(id: string, content: Component & { dispose?(): void }, title: () => string): TuiPanelHandle;
/** Whether the terminal is wide enough to show the panel next to the screen. */
readonly fits: boolean;
/** The id of the panel's content, undefined when closed. */
readonly current: string | undefined;
/** Whether the panel has the focus (it keeps scroll keys; Escape gives the focus back). */
readonly focused: boolean;
/** Moves the focus into the panel or back to the screen. */
focus(focused: boolean): void;
close(): void;
}
/** Commands as seen from contexts: run by id, slash text, palette and help. */
export interface TuiCommandRunner {
/** Runs a registered command if its `when` currently holds. Returns false if unknown or not applicable. */
@ -115,6 +139,7 @@ export interface TuiContext extends RenderContext {
component: Component & Partial<Focusable> & { dispose?(): void },
options?: OverlayOptions,
): TuiOverlayHandle;
readonly panel: TuiPanel;
exit(): void;
reload(): void;
/**

View file

@ -4,6 +4,7 @@ import type { NativeWatchable, Watchable } from "@earendil-works/chord/watch";
import {
type Component,
type Focusable,
HStack,
type KeybindingsManager,
type OverlayHandle,
type OverlayOptions,
@ -12,6 +13,7 @@ import {
VStack,
} from "@earendil-works/pi-tui";
import { BuiltInFooter, BuiltInHeader, type Notice, StatusLines } from "../tui/components/chrome.ts";
import { type PanelContent, PanelFrame } from "../tui/components/panel.ts";
import type { TranscriptAnchor } from "../tui/components/transcript.ts";
import { formatKeyText, lineText } from "../tui/render-context.ts";
import { ThemeRef } from "../tui/theme/ref.ts";
@ -20,7 +22,7 @@ import { defineFacet, type FacetHost, type Host, keyedRegistry } from "./chord.t
import { follow, UI_LINGER } from "./refs.ts";
import { TuiAppearance } from "./tui-appearance.ts";
import { type TuiCommand, TuiCommandDispatcher, TuiCommands, TuiSlashSources } from "./tui-commands.ts";
import type { TuiContext, TuiGrantResult, TuiOverlayHandle, TuiRole } from "./tui-context.ts";
import type { TuiContext, TuiGrantResult, TuiOverlayHandle, TuiPanelHandle, TuiRole } from "./tui-context.ts";
export interface TuiSurface extends Component {
dispose(): void;
@ -141,6 +143,9 @@ export class TuiScope {
}
}
/** Below this many columns the side panel is hidden: the chat would be too narrow next to it. */
const PANEL_MIN_COLUMNS = 100;
/** Host UI state stays outside replaceable facets: selection, drafts and scroll position survive reload. */
export class TuiRuntime implements Component, Focusable {
private isFocused = false;
@ -151,7 +156,11 @@ export class TuiRuntime implements Component, Focusable {
set focused(value: boolean) {
this.isFocused = value;
const focus = this.surfaces?.get(this.screen)?.focus;
if (focus) focus.focused = value;
if (focus) focus.focused = value && !this.panelFocused;
}
/** While the panel has the focus, pi-tui's viewport keys come here (to scroll it) instead of the transcript. */
get capturesViewportInput(): boolean {
return this.panelFocused;
}
/** This TUI's own name (`tui:<uuid>`): the viewer it counts as in session metrics. */
readonly name = `tui:${crypto.randomUUID()}`;
@ -191,6 +200,8 @@ export class TuiRuntime implements Component, Focusable {
private stopCommands: (() => void) | undefined;
private changing = false;
private overlays: { id: string; handle: OverlayHandle; close(): void }[] = [];
private panel: PanelFrame | undefined;
private panelFocused = false;
onExit: () => void = () => {};
onReload: () => void = () => {};
/** The launcher bridge for C actions (set by the worker runtime); denied without one (tests, bare hosts). */
@ -381,6 +392,20 @@ export class TuiRuntime implements Component, Focusable {
surface: (name) => runtime.surfaces.get(name),
notify: (message, kind) => runtime.notify(message, kind),
overlay: (id, component, options) => runtime.overlay(id, component, options),
panel: {
open: (id, content, title) => runtime.openPanel(id, content, title),
get fits() {
return runtime.panelFits();
},
get current() {
return runtime.panel?.id;
},
get focused() {
return runtime.panelFocused;
},
focus: (focused) => runtime.focusPanel(focused),
close: () => runtime.closePanel(),
},
exit: () => runtime.onExit(),
grant: (action, payload) => runtime.onGrant(action, payload),
prefs: {
@ -424,6 +449,8 @@ export class TuiRuntime implements Component, Focusable {
if (this.connected) active.add("connected");
const top = this.overlays.findLast((overlay) => overlay.handle.isFocused());
if (top) active.add("overlay").add(`overlay:${top.id}`);
if (this.panelShown()) active.add("panel");
if (this.panelFocused) active.add("panelFocused");
for (const term of this.surfaces.get(this.screen)?.contexts?.() ?? []) active.add(term);
return active;
}
@ -474,6 +501,73 @@ export class TuiRuntime implements Component, Focusable {
closeOverlays(): void {
for (const overlay of [...this.overlays].reverse()) overlay.close();
}
/** Shows `content` in the side panel, replacing (and disposing) what it showed. */
openPanel(id: string, content: PanelContent, title: () => string): TuiPanelHandle {
this.closePanel();
const frame = new PanelFrame(this.ctx, id, content, title);
this.panel = frame;
this.changed();
const runtime = this;
return {
id,
close: () => {
if (this.panel === frame) this.closePanel();
},
get closed() {
return runtime.panel !== frame;
},
};
}
closePanel(): void {
const frame = this.panel;
if (!frame) return;
this.panel = undefined;
this.focusPanel(false);
frame.content.dispose?.();
this.changed();
}
/** Moves the focus into the panel (`true`) or back to the screen; a hidden or closed panel never takes it. */
focusPanel(focused: boolean): void {
const next = focused && this.panelShown();
if (next === this.panelFocused) return;
this.panelFocused = next;
if (this.panel) this.panel.focused = next;
this.focused = this.isFocused;
this.changed();
}
/** Whether the panel is open and the terminal wide enough to show it. */
private panelShown(): boolean {
return this.panel !== undefined && this.panelFits();
}
private panelFits(): boolean {
return this.tui.terminal.columns >= PANEL_MIN_COLUMNS;
}
/** Keys for the focused panel: back to the screen, scrolling; false for keys it does not use. */
private panelInput(data: string, scroll: PanelFrame["scroll"]): boolean {
const page = Math.max(1, scroll.viewportHeight - 1);
const half = Math.max(1, Math.floor(scroll.viewportHeight / 2));
const steps: [string, number][] = [
["tui.altScreen.pageUp", -page],
["tui.altScreen.pageDown", page],
["tui.select.pageUp", -page],
["tui.select.pageDown", page],
["tui.altScreen.halfPageUp", -half],
["tui.altScreen.halfPageDown", half],
["tui.altScreen.lineUp", -1],
["tui.altScreen.lineDown", 1],
["tui.select.up", -1],
["tui.select.down", 1],
];
if (this.commands.matches(data, "tui.select.cancel")) this.focusPanel(false);
else if (this.commands.matches(data, "tui.altScreen.top")) scroll.scrollToStart();
else if (this.commands.matches(data, "tui.altScreen.bottom")) scroll.scrollToEnd();
else {
const step = steps.find(([id]) => this.commands.matches(data, id));
if (!step) return false;
scroll.scrollBy(step[1]);
}
return true;
}
/** Follows the slot, command and slash registries (once the host published them). */
async start(): Promise<void> {
const [sources, commands, slots] = await Promise.all([
@ -483,8 +577,9 @@ export class TuiRuntime implements Component, Focusable {
]);
const stopSources = follow(sources, (table) => this.commands.setSources(table));
const stopCommands = follow(commands, (table) => {
// A facet reload replaces command values: overlays opened by old values close (section 7.1).
// A facet reload replaces command values: overlays and the panel opened by old values close (section 7.1).
this.closeOverlays();
this.closePanel();
this.commands.setCommands(table);
this.changed();
});
@ -519,18 +614,25 @@ export class TuiRuntime implements Component, Focusable {
const surface = this.surfaces.get(this.screen);
const layout = surface?.layout?.();
const footer = this.surfaces.get("footer") ?? this.builtInFooter;
const parts = [surface, layout?.body, layout?.dock, layout?.header, footer, this.header];
const parts = [surface, layout?.body, layout?.dock, layout?.header, footer, this.header, this.panel];
for (const [name, other] of this.surfaces)
if (other.focus) other.focus.focused = name === this.screen && this.focused;
if (other.focus) other.focus.focused = name === this.screen && this.focused && !this.panelFocused;
if (parts.length === this.rootParts.length && parts.every((part, index) => part === this.rootParts[index]))
return;
this.rootParts = parts;
this.root.clear();
if (layout) this.root.addChild(layout.body, { basis: 0, grow: 1, shrink: 1, minSize: 1 });
else {
this.root.addChild(this.header, { basis: "auto", shrink: 1, minSize: 0 });
this.root.addChild(surface ?? this.placeholder, { basis: 0, grow: 1, shrink: 1, minSize: 1 });
}
const body = layout
? layout.body
: new VStack([
{ component: this.header, basis: "auto", shrink: 1, minSize: 0 },
{ component: surface ?? this.placeholder, basis: 0, grow: 1, shrink: 1, minSize: 1 },
]);
this.root.addChild(this.panel ? this.besidePanel(body, this.panel) : body, {
basis: 0,
grow: 1,
shrink: 1,
minSize: 1,
});
const dock = new VStack([
{ component: this.statusLines, shrink: 1, minSize: 0 },
...(layout?.dock ? [{ component: layout.dock, shrink: 1, minSize: 3 }] : []),
@ -539,6 +641,23 @@ export class TuiRuntime implements Component, Focusable {
this.root.addChild(dock, { basis: "auto", grow: 0, shrink: 1, minSize: 1 });
this.tui.invalidate();
}
/** The screen's body with the panel on its right (about 40% of the width); the panel hides on narrow terminals. */
private besidePanel(body: Component, panel: PanelFrame): Component {
return new HStack(
[
{ component: body, basis: 0, grow: 3, shrink: 1, minSize: 40 },
{
component: panel.root,
basis: 0,
grow: 2,
shrink: 1,
minSize: 40,
visible: (viewport) => viewport.width >= PANEL_MIN_COLUMNS,
},
],
{ gap: 1 },
);
}
fail(error: unknown): void {
this.notify(error instanceof Error ? error.message : String(error), "error");
}
@ -556,7 +675,10 @@ export class TuiRuntime implements Component, Focusable {
navigate(screen: TuiRuntime["screen"], session = this.session, conversation?: number): void {
const next = conversation ?? (session === this.session ? this.conversation : this.mainOf(session));
const changed = session !== this.session || next !== this.conversation;
if (changed) this.closeOverlays();
if (changed) {
this.closeOverlays();
this.closePanel();
}
this.session = session;
this.conversation = next;
this.screen = screen;
@ -565,7 +687,12 @@ export class TuiRuntime implements Component, Focusable {
}
handleInput(data: string): void {
// Section 6.2: the alt screen (stage 2) already ran; commands by context (stage 4), then the screen (stage 5).
if (this.commands.dispatch(data)) this.changed();
// A focused panel takes its keys first and never passes the rest to the screen (typing stays out of the editor).
const panel = this.panelFocused && this.panelShown() ? this.panel : undefined;
if (this.panelFocused && !panel) this.focusPanel(false);
if (panel) {
if (this.panelInput(data, panel.scroll) || this.commands.dispatch(data)) this.changed();
} else if (this.commands.dispatch(data)) this.changed();
else this.surfaces.get(this.screen)?.handleInput?.(data);
this.tui.requestRender();
}
@ -626,6 +753,7 @@ export class TuiRuntime implements Component, Focusable {
}
dispose(): void {
this.closeOverlays();
this.closePanel();
this.stopCommands?.();
this.stopRegistry?.();
for (const surface of this.surfaces.values()) surface.dispose();

View file

@ -0,0 +1,59 @@
// The side panel's frame: a title line over a scrollable body, both behind a one-column bar that separates the panel
// from the chat on its left. The runtime lays it out next to the screen's body and routes keys to it while focused.
import { type Component, ScrollView, truncateToWidth, VStack } from "@earendil-works/pi-tui";
import type { RenderContext } from "../render-context.ts";
/** What a plugin shows in the panel: lines at a width, released when the panel closes. */
export type PanelContent = Component & { dispose?(): void };
export class PanelFrame {
readonly id: string;
readonly content: PanelContent;
readonly scroll: ScrollView;
/** The component the runtime lays out (title over the scroll view). */
readonly root: VStack;
focused = false;
constructor(
ctx: RenderContext & { hints: { key(id: string, description: string): string } },
id: string,
content: PanelContent,
title: () => string,
) {
this.id = id;
this.content = content;
const bar = (focused: boolean) => ctx.theme.current.fg(focused ? "accent" : "border", "│");
const frame = this;
// The bar must reach the bottom of the panel even when the content is shorter than the viewport.
const body: Component = {
render(width) {
const inner = Math.max(1, width - 2);
const lines = content.render(inner);
const height = Math.max(lines.length, frame.scroll.viewportHeight);
const prefix = `${bar(frame.focused)} `;
return Array.from({ length: height }, (_, row) => prefix + (lines[row] ?? ""));
},
invalidate: () => content.invalidate(),
};
this.scroll = new ScrollView(body, {
overscroll: "contain",
scrollbar: "auto",
scrollbarTrackStyle: (text) => ctx.theme.current.fg("scrollbarTrack", text),
scrollbarThumbStyle: (text) => ctx.theme.current.fg("scrollbarThumb", text),
});
const header: Component = {
render(width) {
const theme = ctx.theme.current;
const hint = frame.focused
? ` ${ctx.hints.key("tui.select.cancel", "back")}`
: ` ${ctx.hints.key("p2.panel.focus", "focus")}`;
return [truncateToWidth(`${bar(frame.focused)} ${theme.bold(title())}${hint}`, width)];
},
invalidate() {},
};
this.root = new VStack([
{ component: header, basis: 1, shrink: 0 },
{ component: this.scroll, basis: 0, grow: 1, shrink: 1, minSize: 1 },
]);
}
}

View file

@ -270,6 +270,20 @@ export const CORE_COMMANDS: Record<string, TuiCommand> = {
slash: { name: "reload" },
run: (ctx) => ctx.reload(),
},
"p2.panel.focus": {
title: "Focus the side panel or the editor",
category: "View",
keys: "f2",
when: "panel && !overlay",
run: (ctx) => ctx.panel.focus(!ctx.panel.focused),
},
"p2.panel.close": {
title: "Close the side panel",
category: "View",
when: "panel && !overlay",
slash: { name: "close-panel" },
run: (ctx) => ctx.panel.close(),
},
"app.tools.expand": {
title: "Toggle tool output",
category: "View",

View file

@ -360,6 +360,8 @@ test("chat mode: chip, quoted replies and notes, /chat-mode (owner), /reply, @pi
});
const ESC = "\x1b";
const F2 = "\x1bOQ";
const PAGE_DOWN = "\x1b[6~";
const TAB = "\t";
const CTRL_T = "\x14";
const CTRL_P = "\x10";
@ -1027,10 +1029,10 @@ function contentStubs(calls: Calls): Stub[] {
];
}
test("artifacts: the card previews text, /artifacts shows Markdown inline, opens in the browser and downloads", async (t) => {
test("artifacts: the card previews text, /artifacts shows Markdown inline when narrow, opens in the browser and downloads", async (t) => {
const calls: Calls = {};
const lines = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n");
const { screen, send, runtime } = await setup(t, {
const { screen, send, runtime, terminal } = await setup(t, {
calls,
worker: contentStubs(calls),
facets: [tuiCoreFacet, chatTui, statusTui, artifactsTui],
@ -1051,6 +1053,8 @@ test("artifacts: the card previews text, /artifacts shows Markdown inline, opens
runtime.ctx.commands.run("p2.artifacts");
await poll(async () => (await screen()).includes("Artifacts (2)"));
assert.match(await screen(), /the browser needs its own sign-in/);
// Too narrow for the side panel: Enter shows an artifact over the chat.
terminal.resize(80, 24);
send(ENTER);
await poll(
async () => (await screen()).includes("Notes · MARKDOWN · version 2") && (await screen()).includes("Heading"),
@ -1077,6 +1081,90 @@ test("artifacts: the card previews text, /artifacts shows Markdown inline, opens
await poll(() => runtime.notices.some((notice) => notice.text === "Downloaded Page"));
});
test("artifacts: Enter shows one in the side panel, which follows new versions, scrolls when focused and hides when narrow", async (t) => {
const calls: Calls = {};
let list: Cell<{ name: string; title: string; type: string; version: number }[]> | undefined;
const rows = Array.from({ length: 60 }, (_, index) => `row ${index + 1}`).join("\n\n");
const { screen, send, runtime, terminal } = await setup(t, {
calls,
worker: [
[
ArtifactList,
(_input, _principal, self) => {
// One entry per version, as the worker lists them.
list = self.state([
{ name: "notes", title: "Notes", type: "markdown", version: 1 },
{ name: "notes", title: "Notes", type: "markdown", version: 2 },
]);
return { variant: "reader", artifacts: list };
},
],
[
ArtifactContent,
() => ({
variant: "reader",
read: ({ name, version }: { name: string; version: number }) => {
calls.readArtifact = [...(calls.readArtifact ?? []), { name, version }];
const heading = version === 2 ? "Version two" : "Version three";
return {
info: { name, title: "Notes", type: "markdown", version },
content: `# ${heading}\n\n${rows}`,
truncated: false,
};
},
}),
],
],
facets: [tuiCoreFacet, chatTui, statusTui, artifactsTui],
});
runtime.ctx.commands.run("p2.artifacts");
await poll(async () => (await screen()).includes("Artifacts (2)"));
send(DOWN); // the second row: version 2 of the same artifact
send(ENTER);
await poll(
async () => (await screen()).includes("Notes · MARKDOWN · v2") && (await screen()).includes("Version two"),
);
assert.equal(runtime.ctx.panel.current, "artifact:notes");
assert(runtime.contexts().has("panel"));
// Beside the chat, behind its bar; the editor keeps the focus.
assert.match(await screen(), /│ +Version two/);
assert.equal(runtime.ctx.panel.focused, false);
assert(runtime.capturesViewportInput === false);
send(F2);
assert.equal(runtime.ctx.panel.focused, true);
assert(runtime.capturesViewportInput);
assert(runtime.contexts().has("panelFocused"));
send("x"); // typing stays out of the editor
send(PAGE_DOWN);
send(PAGE_DOWN);
await poll(async () => !(await screen()).includes("Version two"));
send(ESC);
assert.equal(runtime.ctx.panel.focused, false);
assert.equal(runtime.ctx.panel.current, "artifact:notes", "Escape only gives the focus back");
// A new version replaces the shown one.
list!.replace(null, [...list!.value, { name: "notes", title: "Notes", type: "markdown", version: 3 }]);
await poll(async () => (await screen()).includes("Notes · MARKDOWN · v3"));
assert.deepEqual(calls.readArtifact, [
{ name: "notes", version: 2 },
{ name: "notes", version: 3 },
]);
// Too narrow for a split: hidden, and it cannot take the focus.
terminal.resize(80, 24);
await poll(async () => !(await screen()).includes("Notes · MARKDOWN · v3"));
assert(!runtime.contexts().has("panel"));
send(F2);
assert.equal(runtime.ctx.panel.focused, false);
terminal.resize(100, 24);
await poll(async () => (await screen()).includes("Notes · MARKDOWN · v3"));
runtime.ctx.commands.run("p2.panel.close");
await poll(async () => !(await screen()).includes("Notes · MARKDOWN · v3"));
assert.equal(runtime.ctx.panel.current, undefined);
});
test("sent files: the card, /downloads previews text, saves and opens through the launcher", async (t) => {
const calls: Calls = {};
const { screen, send, runtime } = await setup(t, {