mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
edc9c69892
commit
d6339fb097
7 changed files with 468 additions and 65 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
59
apps/p2/src/tui/components/panel.ts
Normal file
59
apps/p2/src/tui/components/panel.ts
Normal 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 },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue