diff --git a/apps/p2/docs/p2-notes.md b/apps/p2/docs/p2-notes.md index b0e8e8103..35441538d 100644 --- a/apps/p2/docs/p2-notes.md +++ b/apps/p2/docs/p2-notes.md @@ -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), diff --git a/apps/p2/plugins/artifacts/tui.ts b/apps/p2/plugins/artifacts/tui.ts index 1ba04b757..83405ba50 100644 --- a/apps/p2/plugins/artifacts/tui.ts +++ b/apps/p2/plugins/artifacts/tui.ts @@ -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; + +/** + * 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( + (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" }, ); diff --git a/apps/p2/src/runtime/tui-context.ts b/apps/p2/src/runtime/tui-context.ts index 8f1f1ce6c..848cd2ca3 100644 --- a/apps/p2/src/runtime/tui-context.ts +++ b/apps/p2/src/runtime/tui-context.ts @@ -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 & { dispose?(): void }, options?: OverlayOptions, ): TuiOverlayHandle; + readonly panel: TuiPanel; exit(): void; reload(): void; /** diff --git a/apps/p2/src/runtime/tui.ts b/apps/p2/src/runtime/tui.ts index 9c6b3a9d2..cf6f5d5fb 100644 --- a/apps/p2/src/runtime/tui.ts +++ b/apps/p2/src/runtime/tui.ts @@ -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:`): 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 { 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(); diff --git a/apps/p2/src/tui/components/panel.ts b/apps/p2/src/tui/components/panel.ts new file mode 100644 index 000000000..c1e7ec4f1 --- /dev/null +++ b/apps/p2/src/tui/components/panel.ts @@ -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 }, + ]); + } +} diff --git a/apps/p2/src/tui/core-commands.ts b/apps/p2/src/tui/core-commands.ts index 76e94b8e5..0eafec914 100644 --- a/apps/p2/src/tui/core-commands.ts +++ b/apps/p2/src/tui/core-commands.ts @@ -270,6 +270,20 @@ export const CORE_COMMANDS: Record = { 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", diff --git a/apps/p2/test/p2/tui-p11.test.ts b/apps/p2/test/p2/tui-p11.test.ts index 3116908ff..9d0735034 100644 --- a/apps/p2/test/p2/tui-p11.test.ts +++ b/apps/p2/test/p2/tui-p11.test.ts @@ -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, {