Compare commits

..
Author SHA1 Message Date
Miguel PalhasandClaude Opus 5 9ba1078386 feat(maestro): port the structured pane to React
Closes #246. The transcript, composer and approval controls move from
structured.ts/structuredRender.ts into components under
web/src/components/structured/, against docs/STRUCTURED-VIEW.md: eight row
kinds, both projections, the single-active approval nonce, and diff rendering
with its own line counts.

The pure layers stay shared — structuredDiff.ts computes the diff,
structuredRender.ts still sanitizes markdown — and model.ts holds the
client-side decisions (activity grouping, notice classification, offered
decisions, `/` completion) so they are testable without a DOM.

Like TerminalPane, the pane draws the body only; pane chrome reads the
connection through onStatusChange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:41:18 +01:00
Miguel PalhasandClaude Opus 4.6 f0a1774e4d fix(maestro): integration review fixes for wave 3
- Add #root div to index.html so React entry point can mount
- Replace unsafe type cast in Sidebar with typed CatalogStore selector

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-09 00:29:05 +01:00
Miguel Palhas ac720eb266 Closes #245: port terminal pane to React 2026-09-09 00:26:16 +01:00
Miguel Palhas 0ba83c3d8f Closes #245: port terminal pane (xterm attach) to React
TerminalPane is a thin ref-based wrapper: xterm lifecycle lives in a
single mount effect keyed on sessionId, tearing the socket and terminal
down on cleanup. Preserves webglRenderer.ts's page-wide WebGL context
budget (acquire on mount, release on unmount, re-acquire on context
loss while still mounted) and reconnect.ts's exponential 500ms->10s
backoff. keys.ts's mobile modifier encoding moves into KeyBar.tsx.

Pane chrome (title, disconnect, sessions drawer) stays out of scope;
composition happens one level up.
2026-09-09 00:24:08 +01:00
22 changed files with 5167 additions and 2 deletions
+1
View File
@@ -24,6 +24,7 @@
</head>
<body>
<div id="app"></div>
<div id="root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2 -2
View File
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useStore } from "zustand";
import { ApiRequestError, killSession, renameSession } from "../../api.js";
import type { CatalogStoreApi } from "../../stores/catalogStore.js";
import type { CatalogStore, CatalogStoreApi } from "../../stores/catalogStore.js";
import type { InTheWay, Session } from "../../types.js";
import { sessionBlockers, sessionName } from "../../types.js";
import { buildTree, type TreeNode } from "../../tree.js";
@@ -349,7 +349,7 @@ function useSessions(props: SidebarProps): Session[] {
// `useStore` needs a store every render; the no-store case is served by a
// stable empty stand-in whose result is thrown away below.
props.store ?? EMPTY_STORE,
(s) => (s as { sessions: Map<string, Session> }).sessions,
(s: CatalogStore) => s.sessions,
);
return useMemo(
() => (props.store ? [...fromStore.values()] : (props.sessions ?? [])),
@@ -0,0 +1,84 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApprovalView } from "./ApprovalView.js";
afterEach(cleanup);
const ALL_OPTIONS = [
{ optionId: "a1", name: "Allow once", kind: "allow_once" as const },
{ optionId: "a2", name: "Allow always", kind: "allow_always" as const },
{ optionId: "r1", name: "Reject", kind: "reject_once" as const },
];
function buttons(): string[] {
return [...document.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent ?? "");
}
describe("ApprovalView", () => {
it("asks the operator with the three decisions the daemon exposes", () => {
render(
<ApprovalView
request={{ toolCall: { title: "Write(a.ts)", locations: [{ path: "/repo/a.ts" }] }, options: ALL_OPTIONS }}
pending
onDecide={() => Promise.resolve()}
/>,
);
expect(buttons()).toEqual(["Allow once", "Allow for session", "Deny"]);
expect(screen.getByText("Write(a.ts)")).toBeTruthy();
expect(screen.getByText("/repo/a.ts")).toBeTruthy();
});
it("submits the decision the operator picked", async () => {
const onDecide = vi.fn().mockResolvedValue(undefined);
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={onDecide} />);
fireEvent.click(screen.getByText("Deny"));
await waitFor(() => expect(onDecide).toHaveBeenCalledWith("deny"));
});
it("disables every button once one is pressed, so a decision is sent once", () => {
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={() => new Promise(() => {})} />);
fireEvent.click(screen.getByText("Allow once"));
const all = [...document.querySelectorAll<HTMLButtonElement>(".st-approval-acts button")];
expect(all.every((b) => b.disabled)).toBe(true);
});
it("restores the controls when the decision failed and this approval is still pending", async () => {
const onDecide = vi.fn().mockRejectedValue(new Error("nope"));
render(<ApprovalView request={{ options: ALL_OPTIONS }} pending onDecide={onDecide} />);
fireEvent.click(screen.getByText("Allow once"));
await waitFor(() => {
const all = [...document.querySelectorAll<HTMLButtonElement>(".st-approval-acts button")];
expect(all.every((b) => !b.disabled)).toBe(true);
});
});
it("offers no button for a decision the request cannot back", () => {
render(
<ApprovalView
request={{ options: [{ optionId: "a1", name: "Allow once", kind: "allow_once" }] }}
pending
onDecide={() => Promise.resolve()}
/>,
);
expect(buttons()).toEqual(["Allow once"]);
});
it("says so when the request offers no decision at all", () => {
render(<ApprovalView request={{ options: [] }} pending onDecide={() => Promise.resolve()} />);
expect(screen.getByText("no decision offered")).toBeTruthy();
});
it("renders a resolved approval as a one-liner, with no controls", () => {
render(
<ApprovalView
request={{ toolCall: { title: "Write(a.ts)" }, options: ALL_OPTIONS }}
pending={false}
onDecide={() => Promise.resolve()}
/>,
);
expect(screen.getByText("Write(a.ts) — approved")).toBeTruthy();
expect(document.querySelector(".st-approval-acts")).toBeNull();
});
});
@@ -0,0 +1,80 @@
// An `Approval` row. The daemon exposes exactly three decisions
// (docs/STRUCTURED-VIEW.md) and mints the nonce; this only solicits one of
// them. A decision the request offers no matching ACP option for is not shown
// — its button would "allow" something that silently resolves `Cancelled`.
import { useState } from "react";
import type { AcpPermissionOption, AcpToolCallLike, StructuredApprovalDecision } from "../../types.js";
import { offeredDecisions, toolKindLabel } from "./model.js";
const SPECS: [StructuredApprovalDecision, string, string][] = [
["allow_once", "Allow once", "primary"],
["allow_for_session", "Allow for session", ""],
["deny", "Deny", "deny"],
];
export interface ApprovalViewProps {
/** The `RequestPermissionRequest` the row carries — serialized without its
* nonce, which never leaves the daemon's live wire. */
request: { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
/** Whether this is the one live approval. A restored/answered row is not. */
pending: boolean;
/** Resolves once the decision reached the daemon; rejects only while this
* approval is still the pending one, so the buttons can come back. */
onDecide: (decision: StructuredApprovalDecision) => Promise<void>;
}
export function ApprovalView({ request, pending, onDecide }: ApprovalViewProps) {
const [busy, setBusy] = useState(false);
const toolCall = request.toolCall ?? {};
const what = toolCall.title ?? toolKindLabel(toolCall.kind);
// Resolved approvals render as a compact one-liner inside a tool group (or
// are dropped entirely when standalone — see model.ts's transcriptItems).
if (!pending) {
return <div className="st-row st-row-approval st-approval-compact">{`${what} — approved`}</div>;
}
const offered = offeredDecisions(request.options ?? []);
const path = toolCall.locations?.[0]?.path;
const decide = (decision: StructuredApprovalDecision) => {
setBusy(true);
onDecide(decision).catch(() => {
// Rejects only while this approval is still pending — restore the
// controls so the operator can retry. The error itself is surfaced by
// the pane's banner.
setBusy(false);
});
};
return (
<div className="st-row st-row-approval">
<div className="st-approval">
<div className="st-approval-title">
<span aria-hidden="true">⚠</span> permission requested
</div>
<div className="st-approval-what">{what}</div>
{path && <div className="st-approval-path">{path}</div>}
{offered.length > 0 ? (
<div className="st-approval-acts">
{SPECS.filter(([decision]) => offered.includes(decision)).map(([decision, label, cls]) => (
<button
key={decision}
type="button"
className={cls ? `act ${cls}` : "act"}
disabled={busy}
onClick={() => decide(decision)}
>
{label}
</button>
))}
</div>
) : (
<div className="st-approval-resolved">no decision offered</div>
)}
</div>
</div>
);
}
+391
View File
@@ -0,0 +1,391 @@
// The composer: draft, attachments, `/` command autocomplete off the live
// `available_commands_update` notices, the session-settings menu, and the
// send/cancel pair. Sending is the pane's job — this only hands it a draft and
// the files that go with it.
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ChangeEvent,
type ClipboardEvent,
type DragEvent,
type FormEvent,
type KeyboardEvent,
} from "react";
import type { AcpSessionConfigChoice, AcpSessionConfigOption } from "../../types.js";
import {
applyCommand,
commandTokenAt,
filterCommands,
orderConfigOptions,
type AvailableCommand,
} from "./model.js";
/** Per-message ceilings the daemon enforces on uploads
* (docs/STRUCTURED-VIEW.md, "Prompt and cancel"). */
export const MAX_ATTACHMENTS = 10;
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
/** Pasted text at or above either bound becomes a `.txt` attachment instead of
* going inline. */
const PASTE_TO_FILE_CHARS = 4_000;
const PASTE_TO_FILE_LINES = 40;
export interface ComposerProps {
/** Files chosen but not yet sent. Owned by the pane so a failed send can put
* them back. */
attachments: File[];
onAttachmentsChange(files: File[]): void;
commands: AvailableCommand[];
configOptions: AcpSessionConfigOption[];
/** A turn is in flight — the cancel control replaces nothing, it appears
* beside send (a queued message is still allowed). */
turnActive: boolean;
/** An upload/POST is in flight: the attachment controls are frozen. */
uploading: boolean;
configBusy: boolean;
/** Restored into the box when a send fails. */
draft: string;
onDraftChange(draft: string): void;
onSubmit(text: string, attachments: File[]): void;
onCancel(): void;
onConfigChange(option: AcpSessionConfigOption, value: string | boolean): void;
onError(message: string): void;
menuId: string;
}
function configChoices(option: AcpSessionConfigOption): AcpSessionConfigChoice[] {
return (option.options ?? []).flatMap((choice) => ("options" in choice ? choice.options : [choice]));
}
function currentConfigLabel(option: AcpSessionConfigOption): string {
if (typeof option.currentValue === "boolean") return option.currentValue ? "On" : "Off";
return configChoices(option).find((choice) => choice.value === option.currentValue)?.name
?? String(option.currentValue);
}
export function Composer({
attachments,
onAttachmentsChange,
commands,
configOptions,
turnActive,
uploading,
configBusy,
draft,
onDraftChange,
onSubmit,
onCancel,
onConfigChange,
onError,
menuId,
}: ComposerProps) {
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const fileRef = useRef<HTMLInputElement | null>(null);
const configRef = useRef<HTMLDivElement | null>(null);
const [caret, setCaret] = useState(0);
const [activeCommand, setActiveCommand] = useState(0);
const [configOpen, setConfigOpen] = useState(false);
/** Escape closes the menu until the draft or caret moves again. */
const [menuDismissed, setMenuDismissed] = useState(false);
const pasteCount = useRef(0);
// Grow with the draft, up to the CSS max-height; past that it scrolls.
useLayoutEffect(() => {
const input = inputRef.current;
if (!input) return;
input.style.height = "auto";
input.style.overflowY = "hidden";
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
if (input.scrollHeight > 160) input.style.overflowY = "auto";
}, [draft]);
useEffect(() => {
if (!configOpen) return;
const onPointerDown = (event: PointerEvent) => {
if (!configRef.current?.contains(event.target as Node)) setConfigOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [configOpen]);
const token = commandTokenAt(draft, caret, caret);
const matches = token && commands.length > 0 ? filterCommands(commands, token.query) : [];
const menuOpen = matches.length > 0 && !menuDismissed;
const activeIndex = menuOpen ? Math.min(Math.max(activeCommand, 0), matches.length - 1) : -1;
const addFiles = useCallback(
(files: File[]) => {
if (uploading) return;
const next = [...attachments];
for (const file of files) {
if (file.size > MAX_ATTACHMENT_BYTES) {
onError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
} else if (next.length >= MAX_ATTACHMENTS) {
onError(`Attach up to ${MAX_ATTACHMENTS} files per message.`);
} else {
next.push(file);
}
}
onAttachmentsChange(next);
},
[attachments, onAttachmentsChange, onError, uploading],
);
const syncCaret = () => {
setMenuDismissed(false);
setCaret(inputRef.current?.selectionStart ?? 0);
};
const chooseCommand = (command: AvailableCommand) => {
const input = inputRef.current;
if (!input) return;
const current = commandTokenAt(draft, input.selectionStart, input.selectionEnd);
if (!current) return;
const applied = applyCommand(draft, current, command.name);
onDraftChange(applied.value);
setActiveCommand(0);
// The caret has to move after React has written the new value.
requestAnimationFrame(() => {
input.focus();
input.setSelectionRange(applied.cursor, applied.cursor);
setCaret(applied.cursor);
});
};
const submit = () => {
if ((!draft.trim() && attachments.length === 0) || uploading) return;
onSubmit(draft, attachments);
};
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (menuOpen) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const direction = event.key === "ArrowDown" ? 1 : -1;
setActiveCommand((index) => (index + direction + matches.length) % matches.length);
return;
}
if ((event.key === "Enter" && !event.shiftKey) || event.key === "Tab") {
event.preventDefault();
chooseCommand(matches[activeIndex]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
setMenuDismissed(true);
return;
}
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
};
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
const clipboard = event.clipboardData;
if (!clipboard || uploading) return;
if (clipboard.files.length) {
event.preventDefault();
addFiles(Array.from(clipboard.files));
return;
}
const text = clipboard.getData("text/plain");
if (text.length < PASTE_TO_FILE_CHARS && text.split("\n").length < PASTE_TO_FILE_LINES) return;
// No slot for it, or too big to upload: leave it inline rather than
// dropping it on the floor.
if (attachments.length >= MAX_ATTACHMENTS || new Blob([text]).size > MAX_ATTACHMENT_BYTES) return;
event.preventDefault();
addFiles([new File([text], `pasted-${++pasteCount.current}.txt`, { type: "text/plain" })]);
};
const ordered = orderConfigOptions(configOptions);
const summary = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n");
return (
<form
className="structured-composer"
onSubmit={(event: FormEvent) => {
event.preventDefault();
submit();
}}
onDragOver={(event: DragEvent) => {
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
}}
onDrop={(event: DragEvent) => {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
addFiles(Array.from(event.dataTransfer.files));
}}
>
<button
type="button"
className="structured-attach"
aria-label="Attach files"
title="Attach files (up to 20 MiB each)"
disabled={uploading}
onClick={() => fileRef.current?.click()}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9" />
</svg>
</button>
<input
ref={fileRef}
className="structured-file-input"
type="file"
multiple
hidden
disabled={uploading}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
addFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
<div className="structured-input-wrap">
<div className="structured-attachments" aria-label="Attachments" aria-live="polite" hidden={attachments.length === 0}>
{attachments.map((file, index) => (
<span key={`${file.name}-${index}`} className="structured-attachment">
<span title={`${file.name} (${file.size.toLocaleString()} bytes)`}>{file.name}</span>
<button
type="button"
aria-label={`Remove ${file.name}`}
disabled={uploading}
onClick={() => onAttachmentsChange(attachments.filter((_, i) => i !== index))}
>
×
</button>
</span>
))}
</div>
<div id={menuId} className="structured-command-menu" role="listbox" hidden={!menuOpen}>
{matches.map((command, index) => (
<div
key={command.name}
id={`${menuId}-option-${index}`}
className="structured-command-option"
role="option"
aria-selected={index === activeIndex}
onPointerDown={(event) => event.preventDefault()}
onClick={() => chooseCommand(command)}
>
<div className="structured-command-title">
<span className="structured-command-name">{`/${command.name}`}</span>
<span className="structured-command-hint">{command.input?.hint ?? ""}</span>
</div>
<div className="structured-command-description">
{command.description.replace(/\s+/g, " ").trim()}
</div>
</div>
))}
</div>
<textarea
ref={inputRef}
className="structured-input"
rows={1}
placeholder="Reply, or / for commands…"
autoComplete="off"
aria-autocomplete="list"
aria-expanded={menuOpen}
aria-controls={menuId}
aria-activedescendant={activeIndex >= 0 ? `${menuId}-option-${activeIndex}` : undefined}
value={draft}
onChange={(event) => {
onDraftChange(event.target.value);
setActiveCommand(0);
setMenuDismissed(false);
setCaret(event.target.selectionStart ?? event.target.value.length);
}}
onClick={syncCaret}
onSelect={syncCaret}
onKeyUp={(event) => {
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) syncCaret();
}}
onKeyDown={onKeyDown}
onPaste={onPaste}
/>
</div>
<div className="structured-config" ref={configRef} hidden={ordered.length === 0}>
<button
type="button"
className="structured-config-trigger"
aria-haspopup="true"
aria-expanded={configOpen}
aria-label={`Session settings. ${summary.replaceAll("\n", ". ")}`}
title={summary}
disabled={configBusy}
onClick={() => setConfigOpen((open) => !open)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h10M18 7h2M4 17h2M10 17h10M14 4v6M6 14v6" />
</svg>
</button>
<div
className="structured-config-menu"
role="group"
aria-label="Session settings"
hidden={!configOpen}
onKeyDown={(event) => {
if (event.key !== "Escape") return;
setConfigOpen(false);
configRef.current?.querySelector<HTMLButtonElement>(".structured-config-trigger")?.focus();
}}
>
{ordered.map((option) => (
<label key={option.id} className="structured-config-row" title={option.description ?? undefined}>
<span className="structured-config-name">{option.name}</span>
{option.type === "select" ? (
<select
className="structured-config-select"
disabled={configBusy}
value={String(option.currentValue)}
onChange={(event) => onConfigChange(option, event.target.value)}
>
{configChoices(option).map((choice) => (
<option key={choice.value} value={choice.value} title={choice.description ?? ""}>
{choice.name}
</option>
))}
</select>
) : option.type === "boolean" ? (
<input
type="checkbox"
className="structured-config-toggle"
disabled={configBusy}
checked={option.currentValue === true}
onChange={(event) => onConfigChange(option, event.target.checked)}
/>
) : null}
</label>
))}
</div>
</div>
<button type="submit" className="structured-send">
<span>Send</span>
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="m7 5 5 5-5 5M12 10H3" />
</svg>
</button>
{turnActive && (
<button
type="button"
className="structured-cancel"
aria-label="Cancel turn"
title="Cancel turn"
onClick={onCancel}
>
<svg className="structured-cancel-spinner" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2" strokeDasharray="50 14" strokeLinecap="round" />
</svg>
<svg className="structured-cancel-stop" viewBox="0 0 24 24" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" />
</svg>
</button>
)}
</form>
);
}
+101
View File
@@ -0,0 +1,101 @@
// docs/STRUCTURED-VIEW.md: the web client's user, agent, thought, tool-detail
// and notice text blocks copy their complete source text when clicked or
// keyboard-activated. The visible node is whatever it wraps; this only adds
// the affordance and the "copied"/"error" state the CSS reads.
import { useCallback, useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
async function writeClipboard(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand?.("copy") ?? false;
textarea.remove();
if (!copied) throw new Error("clipboard unavailable");
}
}
export interface CopyableProps {
/** The complete source text — not whatever the DOM happens to show. */
text: string;
/** Names the thing in the "Copy …" label ("message", "diff", "path", …). */
label: string;
as?: ElementType;
className?: string;
title?: string;
children?: ReactNode;
/** Extra attributes the row builders set (`data-row-id`, `lang`, …). */
[key: string]: unknown;
}
/** Wrapping a node in a copy affordance. Empty `text` renders the node plain —
* a control that copies nothing is worse than no control. */
export function Copyable({
text,
label,
as: Tag = "div",
className,
children,
...rest
}: CopyableProps) {
const [copyState, setCopyState] = useState<"copied" | "error" | null>(null);
const timer = useRef<number | null>(null);
useEffect(
() => () => {
if (timer.current !== null) window.clearTimeout(timer.current);
},
[],
);
const copy = useCallback(() => {
void writeClipboard(text).then(
() => {
setCopyState("copied");
if (timer.current !== null) window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopyState(null), 1200);
},
() => setCopyState("error"),
);
}, [text]);
if (!text) {
return (
<Tag className={className} {...rest}>
{children}
</Tag>
);
}
const ariaLabel = copyState === "copied" ? `${label} copied` : `Copy ${label}`;
return (
<Tag
className={className ? `${className} st-copyable` : "st-copyable"}
tabIndex={0}
role="button"
aria-label={ariaLabel}
title={`Copy ${label}`}
data-copy-state={copyState ?? undefined}
onClick={(event: React.MouseEvent) => {
// A link inside the block is a link, not a copy target.
if ((event.target as Element | null)?.closest("a")) return;
copy();
}}
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
copy();
}}
{...rest}
>
{children}
</Tag>
);
}
@@ -0,0 +1,50 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { DiffView } from "./DiffView.js";
import { toolDiffMeta } from "./model.js";
afterEach(cleanup);
function lines(kind: string): string[] {
return [...document.querySelectorAll(`.st-ln-${kind}`)].map((el) => el.textContent ?? "");
}
describe("DiffView", () => {
it("marks insertions and deletions with their column prefixes", () => {
render(<DiffView diff={{ path: "src/a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }} />);
expect(lines("del")).toEqual(["- two"]);
expect(lines("add")).toEqual(["+ TWO", "+ three"]);
expect(lines("ctx")).toEqual([" one"]);
expect(screen.getByText("src/a.ts")).toBeTruthy();
});
it("carries the +added −removed counts the tool head shows", () => {
render(<DiffView diff={{ path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }} />);
const body = document.querySelector<HTMLElement>(".st-diff")!;
expect(body.dataset.added).toBe("2");
expect(body.dataset.removed).toBe("1");
expect(toolDiffMeta([{ type: "diff", path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }]))
.toBe("+2 −1");
});
it("renders every line of a new file as added", () => {
render(<DiffView diff={{ path: "new.ts", newText: "alpha\nbeta\n" }} />);
expect(lines("add")).toEqual(["+ alpha", "+ beta"]);
expect(lines("del")).toEqual([]);
});
it("collapses a long unchanged run to a hunk summary", () => {
const filler = Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n");
render(<DiffView diff={{ path: "a.ts", oldText: `head\n${filler}\n`, newText: `HEAD\n${filler}\n` }} />);
expect(lines("hunk")).toEqual(["⋯ 17 unchanged lines ⋯"]);
});
it("copies the whole diff, prefixes included", () => {
render(<DiffView diff={{ path: "a.ts", oldText: "one\n", newText: "two\n" }} />);
const body = document.querySelector<HTMLElement>(".st-diff")!;
expect(body.getAttribute("aria-label")).toBe("Copy diff");
expect(body.textContent).toBe("- one+ two");
});
});
@@ -0,0 +1,56 @@
// A `Diff` content block, rendered as coloured hunks. ACP hands over whole
// `oldText`/`newText`, so the lines and their `+added −removed` counts are
// computed here (structuredDiff.ts) rather than parsed out of a patch.
import { useMemo } from "react";
import { computeDiffLines, diffStats, type DiffLine } from "../../structuredDiff.js";
import type { AcpDiff } from "../../types.js";
import { Copyable } from "./Copyable.js";
/** Column prefix by line kind. A `hunk` summary and an `eof` marker are their
* own complete text, so neither takes one. */
const PREFIX: Record<DiffLine["kind"], string> = {
add: "+ ",
del: "- ",
ctx: " ",
hunk: "",
eof: "",
};
export function diffLineText(line: DiffLine): string {
return PREFIX[line.kind] + line.text;
}
export interface DiffViewProps {
diff: AcpDiff;
}
export function DiffView({ diff }: DiffViewProps) {
const lines = useMemo(() => computeDiffLines(diff.oldText, diff.newText), [diff.oldText, diff.newText]);
const stats = useMemo(() => diffStats(lines), [lines]);
const plain = useMemo(() => lines.map(diffLineText).join("\n"), [lines]);
return (
<div className="st-diff-block">
<Copyable className="st-diff-path" text={diff.path} label="path" title={diff.path}>
{diff.path}
</Copyable>
<Copyable
className="st-diff"
text={plain}
label="diff"
data-added={stats.added}
data-removed={stats.removed}
>
<pre>
{lines.map((line, index) => (
<span key={index} className={`st-ln st-ln-${line.kind}`}>
{diffLineText(line)}
</span>
))}
</pre>
</Copyable>
</div>
);
}
@@ -0,0 +1,403 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../api.js", async () => {
const actual = await vi.importActual<typeof import("../../api.js")>("../../api.js");
return {
...actual,
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
uploadStructuredFile: vi.fn().mockResolvedValue({ path: "/uploads/pasted.txt" }),
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
setStructuredConfig: vi.fn().mockResolvedValue(undefined),
};
});
import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
uploadStructuredFile,
} from "../../api.js";
import { INITIAL_RECONNECT_MS } from "../../reconnect.js";
import type { ReducedState, StructuredEvent, TranscriptRow } from "../../types.js";
import { StructuredPane } from "./StructuredPane.js";
/** Just enough of a WebSocket for the pane to drive (mirrors structured.test.ts's
* own fake, which the pre-React connection used). */
class FakeSocket {
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
closed = false;
open(): void {
act(() => this.onopen?.());
}
emit(event: StructuredEvent): void {
act(() => this.onmessage?.({ data: JSON.stringify(event) }));
}
drop(code = 1006, reason = ""): void {
act(() => this.onclose?.({ code, reason }));
}
close(): void {
this.closed = true;
}
}
const idle: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
const busy: ReducedState = { turn: { responding: true, thinking: false, active_tool_calls: [] } };
function row(id: string, kind: TranscriptRow["kind"], data: unknown = {}): TranscriptRow {
return { id, kind, data };
}
function chunk(id: string, kind: "user" | "agent_text" | "agent_thought", text: string): TranscriptRow {
return row(id, kind, { content: { type: "text", text } });
}
const APPROVAL_REQUEST = {
toolCall: { title: "Write(secrets.txt)", kind: "edit", locations: [{ path: "/repo/secrets.txt" }] },
options: [
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
{ optionId: "a2", name: "Allow always", kind: "allow_always" },
{ optionId: "r1", name: "Reject", kind: "reject_once" },
],
};
function mount(): { socket: FakeSocket; sockets: FakeSocket[] } {
const sockets: FakeSocket[] = [];
render(
<StructuredPane
sessionId="s1"
socketFactory={() => {
const socket = new FakeSocket();
sockets.push(socket);
return socket as unknown as WebSocket;
}}
/>,
);
sockets[0].open();
return { socket: sockets[0], sockets };
}
function snapshot(socket: FakeSocket, rows: TranscriptRow[], state: ReducedState = idle): void {
socket.emit({ type: "transcript_snapshot", snapshot: { cursor: rows.length, rows } });
socket.emit({ type: "reduced_state", state });
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe("the eight row kinds", () => {
it("renders user, agent, thought, tool, diff, approval and notice rows; never a plan", () => {
const { socket } = mount();
snapshot(
socket,
[
chunk("user:1", "user", "please edit it"),
chunk("agent:1", "agent_text", "**bold** answer"),
chunk("thought:1", "agent_thought", "considering"),
row("tool:1", "tool_call", { title: "Read(src/main.ts)", kind: "read", status: "completed" }),
row("tool:2", "tool_diff", {
title: "Edit(a.ts)",
kind: "edit",
status: "completed",
content: [{ type: "diff", path: "a.ts", oldText: "one\n", newText: "one\ntwo\n" }],
}),
row("approval:1", "approval", APPROVAL_REQUEST),
row("plan", "plan", { entries: [{ content: "step", priority: "high", status: "pending" }] }),
row("notice:1", "notice", { sessionUpdate: "mystery_update" }),
],
{ ...idle, pending_approval: { nonce: "nonce-1", request: APPROVAL_REQUEST as never } },
);
expect(screen.getByText("please edit it")).toBeTruthy();
expect(document.querySelector(".st-row-agent strong")?.textContent).toBe("bold");
expect(document.querySelector(".st-row-thought")?.textContent).toContain("considering");
expect(screen.getByText("src/main.ts")).toBeTruthy();
// The diff row's head carries the line counts, not a fabricated duration.
expect(document.querySelector('[data-row-id="tool:2"] .st-tool-meta')?.textContent).toBe("+1 −0");
expect(screen.getByText("permission requested")).toBeTruthy();
expect(screen.getByText("[notice]")).toBeTruthy();
expect(document.querySelector('[data-row-id="plan"]')).toBeNull();
});
it("omits the state-only notices it consumes for `/` autocomplete", () => {
const { socket } = mount();
snapshot(socket, [
row("notice:1", "notice", {
sessionUpdate: "available_commands_update",
availableCommands: [{ name: "compact", description: "Compact the conversation" }],
}),
row("notice:2", "notice", { sessionUpdate: "session_info_update" }),
]);
expect(screen.queryByText("[notice]")).toBeNull();
// …and offers them in the composer instead.
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
fireEvent.change(input, { target: { value: "/comp" } });
expect(screen.getByText("/compact")).toBeTruthy();
});
it("renders a completed turn's footer inline, and retires it once the next turn starts", () => {
const { socket } = mount();
const footer = row("turn_meta:1", "notice", {
maestroTurnMeta: { elapsedMs: 65_000, outcome: "completed", model: "sonnet", contextDelta: 1200 },
});
snapshot(socket, [chunk("user:1", "user", "go"), footer]);
expect(screen.getByText("1m 5s · +1.2k context tokens · sonnet")).toBeTruthy();
socket.emit({
type: "projection_update",
transcript: { type: "append", row: chunk("user:2", "user", "again") },
state: idle,
});
expect(screen.queryByText(/1m 5s/)).toBeNull();
});
});
describe("tool rows and activity groups", () => {
it("collapses two or more tools in a turn into one group, opened on click", () => {
const { socket } = mount();
snapshot(socket, [
chunk("user:1", "user", "go"),
row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
chunk("agent:1", "agent_text", "still working"),
row("tool:2", "tool_call", { title: "Read(b.ts)", kind: "read", status: "completed" }),
chunk("agent:2", "agent_text", "the answer"),
]);
const head = screen.getByText("2 tool calls").closest("button")!;
expect(head.getAttribute("aria-expanded")).toBe("false");
// The final answer stays outside the group; the intermediate text is in it.
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("the answer"))).toBe(false);
expect(document.querySelector(".st-tool-group")!.contains(screen.getByText("still working"))).toBe(true);
fireEvent.click(head);
expect(head.getAttribute("aria-expanded")).toBe("true");
});
it("discloses a tool's output only once its head is opened", () => {
const { socket } = mount();
snapshot(socket, [
row("tool:1", "tool_call", {
title: "Execute(ls)",
kind: "execute",
status: "completed",
content: [{ type: "content", content: { type: "text", text: "a.ts\nb.ts" } }],
rawInput: { command: "ls" },
rawOutput: { exit_code: 0 },
}),
]);
expect(document.querySelector(".st-tool-text")).toBeNull();
fireEvent.click(document.querySelector<HTMLButtonElement>(".st-tool-head")!);
expect(document.querySelector(".st-tool-text")?.textContent).toBe("a.ts\nb.ts");
expect(screen.getByText("Exit code")).toBeTruthy();
expect(document.querySelector(".st-tool-exit-ok")?.textContent).toBe("0");
});
it("patches a tool row in place rather than appending a second one", () => {
const { socket } = mount();
snapshot(socket, [row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "in_progress" })]);
expect(document.querySelector(".st-tool-meta")?.textContent).toBe("running");
socket.emit({
type: "projection_update",
transcript: {
type: "patch",
row: row("tool:1", "tool_call", { title: "Read(a.ts)", kind: "read", status: "completed" }),
},
state: idle,
});
expect(document.querySelectorAll('[data-row-id="tool:1"]')).toHaveLength(1);
expect(document.querySelector(".st-tool-meta")).toBeNull();
});
});
describe("approvals", () => {
it("answers with the live nonce, offering only the decisions the request backs", async () => {
const { socket } = mount();
snapshot(socket, [row("approval:1", "approval", APPROVAL_REQUEST)], {
...idle,
pending_approval: { nonce: "nonce-1", request: APPROVAL_REQUEST as never },
});
expect(screen.getByText("/repo/secrets.txt")).toBeTruthy();
fireEvent.click(screen.getByText("Allow for session"));
await waitFor(() =>
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "nonce-1", "allow_for_session"),
);
});
it("hides a decision the request offers no ACP option for", () => {
const { socket } = mount();
const request = { toolCall: { title: "Read(a.ts)" }, options: [{ optionId: "r", name: "No", kind: "reject_once" }] };
snapshot(socket, [row("approval:1", "approval", request)], {
...idle,
pending_approval: { nonce: "nonce-2", request: request as never },
});
expect(screen.getByText("Deny")).toBeTruthy();
expect(screen.queryByText("Allow once")).toBeNull();
});
it("treats only the newest approval as pending, and hides resolved standalone ones", () => {
const { socket } = mount();
snapshot(
socket,
[row("approval:1", "approval", APPROVAL_REQUEST), row("approval:2", "approval", APPROVAL_REQUEST)],
{ ...idle, pending_approval: { nonce: "nonce-3", request: APPROVAL_REQUEST as never } },
);
expect(screen.getAllByText("permission requested")).toHaveLength(1);
expect(screen.queryByText(/approved/)).toBeNull();
});
it("drops the controls once the approval is no longer pending", () => {
const { socket } = mount();
snapshot(socket, [row("approval:1", "approval", APPROVAL_REQUEST)], {
...idle,
pending_approval: { nonce: "nonce-4", request: APPROVAL_REQUEST as never },
});
expect(screen.getByText("Allow once")).toBeTruthy();
socket.emit({ type: "reduced_state", state: idle });
expect(screen.queryByText("Allow once")).toBeNull();
});
});
describe("prompt and cancel", () => {
it("sends a draft, clearing the composer", async () => {
const { socket } = mount();
snapshot(socket, []);
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
fireEvent.change(input, { target: { value: "do the thing" } });
fireEvent.submit(document.querySelector("form.structured-composer")!);
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing"));
expect(input.value).toBe("");
});
it("uploads attachments and names them in the prompt", async () => {
const { socket } = mount();
snapshot(socket, []);
const file = new File(["hello"], "notes.txt", { type: "text/plain" });
fireEvent.change(document.querySelector<HTMLInputElement>(".structured-file-input")!, {
target: { files: [file] },
});
expect(screen.getByText("notes.txt")).toBeTruthy();
fireEvent.change(document.querySelector<HTMLTextAreaElement>(".structured-input")!, {
target: { value: "read this" },
});
fireEvent.submit(document.querySelector("form.structured-composer")!);
await waitFor(() => expect(uploadStructuredFile).toHaveBeenCalled());
await waitFor(() =>
expect(sendStructuredPrompt).toHaveBeenCalledWith(
"s1",
'read this\n\nAttached file "notes.txt": "/uploads/pasted.txt"',
),
);
});
it("queues a second message while a turn is in flight, and sends it when the turn ends", async () => {
const { socket } = mount();
snapshot(socket, [], busy);
fireEvent.change(document.querySelector<HTMLTextAreaElement>(".structured-input")!, {
target: { value: "next up" },
});
fireEvent.submit(document.querySelector("form.structured-composer")!);
expect(sendStructuredPrompt).not.toHaveBeenCalled();
expect(document.querySelector(".st-row-queued")?.textContent).toBe("next up");
socket.emit({ type: "reduced_state", state: idle });
await waitFor(() => expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "next up"));
});
it("hands a failed send's draft back to the operator", async () => {
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
const { socket } = mount();
snapshot(socket, []);
const input = document.querySelector<HTMLTextAreaElement>(".structured-input")!;
fireEvent.change(input, { target: { value: "keep me" } });
fireEvent.submit(document.querySelector("form.structured-composer")!);
await waitFor(() => expect(document.querySelector(".structured-error")?.textContent).toContain("boom"));
expect(input.value).toBe("keep me");
});
it("shows the cancel control only while a turn is live", () => {
const { socket } = mount();
snapshot(socket, []);
expect(document.querySelector(".structured-cancel")).toBeNull();
socket.emit({ type: "reduced_state", state: busy });
fireEvent.click(document.querySelector<HTMLButtonElement>(".structured-cancel")!);
expect(cancelStructuredTurn).toHaveBeenCalledWith("s1");
});
it("reports live turn activity above the composer", () => {
const { socket } = mount();
snapshot(socket, [], {
turn: {
responding: true,
thinking: false,
active_tool_calls: ["t1", "t2"],
started_at: new Date(Date.now() - 5_000).toISOString(),
},
usage: { used: 12_000, size: 200_000 },
});
const activity = document.querySelector<HTMLElement>(".structured-activity")!;
expect(activity.hidden).toBe(false);
expect(activity.textContent).toContain("Running 2 tools");
expect(activity.textContent).toContain("12k context / 200k context");
});
});
describe("the socket", () => {
it("replaces the transcript wholesale on every snapshot, never merging", async () => {
const { socket, sockets } = mount();
snapshot(socket, [chunk("user:1", "user", "first")]);
socket.drop(1006);
// The retry is on reconnect.ts's backoff schedule, not immediate.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, INITIAL_RECONNECT_MS + 20));
});
const replacement = sockets[1];
expect(replacement).toBeDefined();
replacement.open();
snapshot(replacement, [chunk("user:2", "user", "second")]);
expect(screen.queryByText("first")).toBeNull();
expect(screen.getByText("second")).toBeTruthy();
});
it("reports a daemon rejection instead of retrying it", () => {
const status = vi.fn();
const sockets: FakeSocket[] = [];
render(
<StructuredPane
sessionId="s1"
onStatusChange={status}
socketFactory={() => {
const socket = new FakeSocket();
sockets.push(socket);
return socket as unknown as WebSocket;
}}
/>,
);
sockets[0].open();
sockets[0].drop(4004, "not a structured session");
expect(sockets).toHaveLength(1);
expect(status).toHaveBeenCalledWith("error", "not a structured session");
});
});
@@ -0,0 +1,400 @@
// React port of structured.ts (#246): a live structured session — the
// transcript from `WS /api/structured/:id`, the composer that drives
// `.../prompt` and `.../cancel`, and the approval controls that answer
// `.../approvals/:nonce`. docs/STRUCTURED-VIEW.md is the contract.
//
// Like TerminalPane, the pane draws the body only: pane chrome (title,
// disconnect, sessions drawer) belongs to whatever composes it, which reads
// the connection state through `onStatusChange`.
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
setStructuredConfig,
uploadStructuredFile,
} from "../../api.js";
import { formatContextTokens, formatElapsed } from "../../structuredRender.js";
import type {
AcpSessionConfigOption,
StructuredApprovalDecision,
TranscriptRow as Row,
Uuid,
} from "../../types.js";
import { Composer } from "./Composer.js";
import {
commandsFromTranscript,
pendingApprovalRowId as pendingApprovalRowIdOf,
transcriptItems,
turnIsActive,
} from "./model.js";
import { ToolGroup } from "./ToolGroup.js";
import { TranscriptRow } from "./TranscriptRow.js";
import { useStructuredSession, type StructuredStatus, type StructuredSocketFactory } from "./useStructuredSession.js";
import "./structured.css";
/** Within this much of the bottom, wheel and touch imprecision should still
* count as following. */
const FOLLOW_BOTTOM_THRESHOLD_PX = 64;
interface QueuedPrompt {
text: string;
attachments: File[];
}
export interface StructuredPaneProps {
sessionId: Uuid;
/** Grabs the composer once mounted. A sidebar preview passes `false`. */
focus?: boolean;
shouldReconnect?: (code: number) => boolean;
onClosed?: () => void;
onStatusChange?: (status: StructuredStatus, detail?: string) => void;
/** Test seam: drive the socket without a daemon. */
socketFactory?: StructuredSocketFactory;
className?: string;
}
export interface StructuredPaneHandle {
/** Put the keyboard in the composer — the structured pane's terminal. */
focusComposer(): void;
}
export const StructuredPane = forwardRef<StructuredPaneHandle, StructuredPaneProps>(function StructuredPane(
{ sessionId, focus = true, shouldReconnect, onClosed, onStatusChange, socketFactory, className },
ref,
) {
const session = useStructuredSession(sessionId, { shouldReconnect, onClosed, socketFactory });
const { rows, state, status, detail, socketGeneration } = session;
const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLDivElement | null>(null);
const followingBottom = useRef(true);
const [expandedRows, setExpandedRows] = useState<ReadonlySet<string>>(new Set());
const [expandedGroups, setExpandedGroups] = useState<ReadonlySet<string>>(new Set());
const [draft, setDraft] = useState("");
const [attachments, setAttachments] = useState<File[]>([]);
const [queue, setQueue] = useState<QueuedPrompt[]>([]);
const [uploading, setUploading] = useState(false);
const [configBusy, setConfigBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
/** Server path → object URL, so a user row can preview an image it uploaded. */
const imageUrls = useRef(new Map<string, string>());
// Submission bookkeeping, ported from structured.ts: a prompt is "sending"
// from the POST until the projection shows the turn it claimed, and an
// acceptance that straddles a reconnect forces one more reconnect so the
// snapshot cannot predate the 204.
const submission = useRef(0);
const accepted = useRef<{ generation: number; socketGeneration: number } | null>(null);
const optimisticStart = useRef<number | null>(null);
const promptPending = useRef(false);
const [awaitingBegin, setAwaitingBegin] = useState<number | null>(null);
useImperativeHandle(
ref,
() => ({
focusComposer: () => composerRef.current?.querySelector<HTMLTextAreaElement>(".structured-input")?.focus(),
}),
[],
);
useEffect(() => {
onStatusChange?.(status, detail ?? undefined);
}, [status, detail, onStatusChange]);
useEffect(() => {
if (!focus) return;
composerRef.current?.querySelector<HTMLTextAreaElement>(".structured-input")?.focus();
}, [focus]);
const urls = imageUrls.current;
useEffect(
() => () => {
for (const url of urls.values()) URL.revokeObjectURL(url);
urls.clear();
},
[urls],
);
const turnActive = turnIsActive(state);
const pendingRowId = useMemo(() => pendingApprovalRowIdOf(rows, state), [rows, state]);
const items = useMemo(
() => transcriptItems(rows, pendingRowId, turnActive),
[rows, pendingRowId, turnActive],
);
const commands = useMemo(() => commandsFromTranscript(rows), [rows]);
const configOptions: AcpSessionConfigOption[] = state.config_options?.configOptions ?? [];
const active = turnActive || awaitingBegin !== null;
// Stop saying "Sending" once the turn the submission was waiting for has
// begun — or, when it was accepted but a replacement socket has
// re-snapshotted since, once that newer snapshot has landed. Without the
// second case a prompt accepted across a reconnect would say "Sending"
// forever.
useEffect(() => {
if (turnActive) {
setAwaitingBegin(null);
} else if (
awaitingBegin !== null &&
accepted.current?.generation === awaitingBegin &&
socketGeneration > accepted.current.socketGeneration
) {
setAwaitingBegin(null);
}
}, [turnActive, awaitingBegin, socketGeneration]);
useEffect(() => {
if (!active) optimisticStart.current = null;
}, [active]);
// Keep the pane pinned to the newest row unless the operator scrolled away.
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el || !followingBottom.current) return;
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
}, [items, active, queue]);
useEffect(() => {
if (!active) return;
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
return () => window.clearInterval(timer);
}, [active]);
const onScroll = () => {
const el = scrollRef.current;
if (!el) return;
followingBottom.current = el.scrollHeight - el.clientHeight - el.scrollTop <= FOLLOW_BOTTOM_THRESHOLD_PX;
};
const decide = useCallback(
async (rowId: string, decision: StructuredApprovalDecision): Promise<void> => {
const nonce = state.pending_approval?.nonce;
// Already resolved elsewhere between the click and here. A stale nonce
// is a no-op, not an error (docs/STRUCTURED-VIEW.md: any nonce answers
// 204), and the row's buttons are already gone — resolve quietly.
if (!nonce || rowId !== pendingRowId) return;
try {
await resolveStructuredApproval(sessionId, nonce, decision);
} catch (e: unknown) {
// Propagate — so the row re-enables its buttons — only while this is
// still the pending approval. If it moved on in flight, reviving the
// buttons would revive a decision that no longer applies.
setError(String(e));
throw e;
}
},
[sessionId, state.pending_approval?.nonce, pendingRowId],
);
const dispatchPrompt = useCallback(
(text: string, files: File[]) => {
const generation = ++submission.current;
const submissionSocket = socketGeneration;
setAwaitingBegin(generation);
optimisticStart.current = Date.now();
accepted.current = null;
promptPending.current = true;
setUploading(files.length > 0);
setDraft("");
setError(null);
const send = async () => {
const references: string[] = [];
for (const file of files) {
const { path } = await uploadStructuredFile(sessionId, file);
if (file.type.startsWith("image/")) imageUrls.current.set(path, URL.createObjectURL(file));
references.push(`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
}
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
};
void send().then(
() => {
promptPending.current = false;
if (generation !== submission.current) return;
setUploading(false);
setAttachments((current) => (current === files ? [] : current));
accepted.current = { generation, socketGeneration };
// A replacement socket may have captured idle before the POST
// claimed the prompt. Reconnect so its snapshot cannot predate the
// 204.
if (socketGeneration > submissionSocket) session.reconnect();
},
(e: unknown) => {
promptPending.current = false;
if (generation !== submission.current) return;
setAwaitingBegin((current) => (current === generation ? null : current));
optimisticStart.current = null;
setUploading(false);
// The draft and its attachments are the operator's work — a failed
// send hands both back rather than eating them.
setAttachments((current) => (current.length === 0 ? files : current));
setDraft((current) => (current === "" ? text : current));
setError(String(e));
},
);
},
[sessionId, socketGeneration, session],
);
// A queued message goes out as soon as the projected turn is idle — the
// daemon answers a second prompt `409 turn_in_flight`, so the queue is
// client-local (docs/STRUCTURED-VIEW.md).
useEffect(() => {
if (active || promptPending.current || queue.length === 0) return;
const [next, ...rest] = queue;
setQueue(rest);
dispatchPrompt(next.text, next.attachments);
}, [active, queue, dispatchPrompt]);
const submit = (text: string, files: File[]) => {
if (active || queue.length > 0) {
setQueue((current) => [...current, { text, attachments: files }]);
setAttachments([]);
setDraft("");
setError(null);
return;
}
dispatchPrompt(text, files);
};
const cancel = () => {
setError(null);
cancelStructuredTurn(sessionId).catch((e: unknown) => setError(String(e)));
};
const changeConfig = (option: AcpSessionConfigOption, value: string | boolean) => {
setConfigBusy(true);
setError(null);
setStructuredConfig(sessionId, { config_id: option.id, value })
.catch((e: unknown) =>
setError(e instanceof Error ? e.message : "Could not change the session setting"),
)
.finally(() => setConfigBusy(false));
};
const elapsedMs = () => {
const authoritative = state.turn.started_at ? Date.parse(state.turn.started_at) : Number.NaN;
const startedAt = Number.isFinite(authoritative) ? authoritative : optimisticStart.current;
return startedAt === null ? 0 : Math.max(0, now - startedAt);
};
const activityLabel = () => {
const tools = state.turn.active_tool_calls.length;
const label = awaitingBegin !== null
? "Sending"
: state.turn.thinking
? "Thinking"
: tools > 0
? `Running ${tools} ${tools === 1 ? "tool" : "tools"}`
: "Responding";
const parts = [label, formatElapsed(elapsedMs())];
if (typeof state.usage?.used === "number") {
const usage = formatContextTokens(state.usage.used);
parts.push(
typeof state.usage.size === "number" ? `${usage} / ${formatContextTokens(state.usage.size)}` : usage,
);
}
return parts.join(" · ");
};
const setRowExpanded = (id: string, expanded: boolean) =>
setExpandedRows((current) => {
const next = new Set(current);
if (expanded) next.add(id);
else next.delete(id);
return next;
});
const setGroupExpanded = (id: string, expanded: boolean) =>
setExpandedGroups((current) => {
const next = new Set(current);
if (expanded) next.add(id);
else next.delete(id);
return next;
});
const renderRow = (row: Row) => (
<TranscriptRow
key={row.id}
row={row}
pendingApproval={row.id === pendingRowId}
onDecide={(decision) => decide(row.id, decision)}
expanded={expandedRows.has(row.id)}
onExpandedChange={(expanded) => setRowExpanded(row.id, expanded)}
imageUrls={imageUrls.current}
/>
);
return (
<div className={className ? `structured-pane ${className}` : "structured-pane"}>
<div className="structured-body">
<div className="structured-main">
<div className="structured-scroll" ref={scrollRef} onScroll={onScroll}>
<div className="structured-rows">
{items.map((item) =>
item.type === "row" ? (
renderRow(item.row)
) : (
<ToolGroup
key={item.id}
id={item.id}
toolCount={item.group.toolCount}
expanded={expandedGroups.has(item.id)}
onExpandedChange={(expanded) => setGroupExpanded(item.id, expanded)}
>
{item.group.rows.map(renderRow)}
</ToolGroup>
),
)}
{queue.map((queued, index) => (
<div key={`queued-${index}`} className="st-row st-row-queued">
<div className="st-body">
<div className="st-user-text">{queued.text}</div>
</div>
</div>
))}
</div>
<div className="structured-activity" role="status" aria-live="polite" hidden={!active}>
<span className="structured-activity-pulse" aria-hidden="true" />
<span className="structured-activity-text">{active ? activityLabel() : ""}</span>
</div>
</div>
{error !== null && <div className="structured-error">{error}</div>}
<div ref={composerRef}>
<Composer
attachments={attachments}
onAttachmentsChange={setAttachments}
commands={commands}
configOptions={configOptions}
turnActive={active}
uploading={uploading}
configBusy={configBusy}
draft={draft}
onDraftChange={setDraft}
onSubmit={submit}
onCancel={cancel}
onConfigChange={changeConfig}
onError={setError}
menuId={`structured-command-menu-${sessionId}`}
/>
</div>
</div>
</div>
</div>
);
});
@@ -0,0 +1,43 @@
// A turn's collapsed activity block: two or more tool rows after the same
// `User` row, plus the narration absorbed between them. The header counts tool
// rows only (docs/STRUCTURED-VIEW.md); opening it reveals tools and text in
// chronological order.
import type { ReactNode } from "react";
export interface ToolGroupProps {
id: string;
toolCount: number;
expanded: boolean;
onExpandedChange(expanded: boolean): void;
children: ReactNode;
}
export function ToolGroup({ id, toolCount, expanded, onExpandedChange, children }: ToolGroupProps) {
return (
<div className="st-tool-group" data-tool-group-id={id}>
<button
type="button"
className="st-tool-group-head"
aria-expanded={expanded}
onClick={() => onExpandedChange(!expanded)}
>
<span className="st-tool-group-glyph" aria-hidden="true">
›
</span>
<span className="st-tool-group-title">{`${toolCount} tool calls`}</span>
</button>
<div
className={
expanded ? "st-tool-group-body st-disclosure is-expanded" : "st-tool-group-body st-disclosure"
}
aria-hidden={!expanded}
inert={!expanded}
>
<div className="st-disclosure-clip">
<div className="st-disclosure-inner">{children}</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
// The glyph column of a tool row's head. Recognition is narrow on purpose: an
// unrecognised or absent `ToolKind` gets the generic bullet, never a blank
// column and never a mark that stands for the wrong kind of work.
import type { AcpToolKind } from "../../types.js";
/** Glyph shown in a tool row's head, by `ToolKind`. */
const KIND_GLYPH: Record<string, string> = {
read: "≡",
edit: "±",
delete: "✕",
move: "→",
search: "⌕",
execute: "$",
think: "…",
fetch: "⇣",
switch_mode: "⇄",
};
export function toolGlyph(kind: string | undefined): string {
return (kind && KIND_GLYPH[kind]) || "•";
}
export interface ToolIconProps {
kind: AcpToolKind | string | undefined;
className?: string;
}
export function ToolIcon({ kind, className = "st-tool-glyph" }: ToolIconProps) {
return (
<span className={className} aria-hidden="true">
{toolGlyph(kind)}
</span>
);
}
@@ -0,0 +1,387 @@
// One transcript row, by kind. docs/STRUCTURED-VIEW.md fixes the eight kinds
// and leaves every rendering decision here: markdown for AgentText, an inline
// italic stream for AgentThought, a tool card for ToolCall/ToolDiff, a diff
// view for ToolDiff's content, an approval card for Approval, and an opaque
// collapsed line for a Notice nobody has taught the client about. `Plan` is
// deliberately not drawn — the web client has no plan rail.
import { useEffect, useRef, type ReactNode } from "react";
import { renderInlineMarkdown, renderMarkdown, formatElapsed } from "../../structuredRender.js";
import type {
AcpContentBlock,
AcpPermissionOption,
AcpToolCallContent,
AcpToolCallLike,
StructuredApprovalDecision,
TranscriptRow as Row,
TurnMeta,
} from "../../types.js";
import { ApprovalView } from "./ApprovalView.js";
import { Copyable } from "./Copyable.js";
import { DiffView } from "./DiffView.js";
import {
contentBlockText,
isStateOnlyNotice,
splitToolTitle,
statusMeta,
toolDiffMeta,
turnMeta,
} from "./model.js";
import { safeMediaSource, toolPayload } from "./toolPayload.js";
import { ToolIcon } from "./ToolIcon.js";
// ---------------------------------------------------------------------------
// content rows: user / agent / thought
// ---------------------------------------------------------------------------
/** Model-authored markdown, sanitized by structuredRender.ts before it enters
* the DOM. Tables get their own scroller and links open out of the app. */
function Prose({ html }: { html: string }) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const container = ref.current;
if (!container) return;
for (const table of Array.from(container.querySelectorAll("table"))) {
if (table.parentElement?.classList.contains("st-markdown-table-wrap")) continue;
const scroller = document.createElement("div");
scroller.className = "st-markdown-table-wrap";
table.replaceWith(scroller);
scroller.appendChild(table);
}
for (const link of container.querySelectorAll("a")) {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
}, [html]);
return <div ref={ref} className="st-body" dangerouslySetInnerHTML={{ __html: html }} />;
}
export interface ContentRowProps {
row: Row;
variant: "user" | "agent" | "thought";
/** Object URLs for images this prompt uploaded, keyed by the server path the
* row's text names. */
imageUrls?: Map<string, string>;
}
/** `Attached file "name": "path"` references the composer appends on send. */
const ATTACHED_FILE_RE = /Attached file "(?:[^"\\]|\\.)*": "((?:[^"\\]|\\.)*)"/g;
function attachedImages(text: string, imageUrls: Map<string, string>): string[] {
const urls: string[] = [];
ATTACHED_FILE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = ATTACHED_FILE_RE.exec(text)) !== null) {
let serverPath: string;
try {
serverPath = JSON.parse(`"${match[1]}"`) as string;
} catch {
continue;
}
const url = imageUrls.get(serverPath);
if (url) urls.push(url);
}
return urls;
}
export function ContentRow({ row, variant, imageUrls }: ContentRowProps) {
const data = row.data as { content?: AcpContentBlock } | undefined;
const text = contentBlockText(data?.content);
const displayText = variant === "thought" ? text.trim() : text;
const label = variant === "user" ? "message" : variant === "agent" ? "response" : "thought";
let body: ReactNode;
if (variant === "user") {
const images = imageUrls && imageUrls.size > 0 ? attachedImages(displayText, imageUrls) : [];
// With previews in hand the path references are noise — the image is the
// attachment. Without them the text is all the operator has, so it stays.
const shown =
images.length > 0
? displayText.replace(/\n\nAttached file "[^"]*": "[^"]*"/g, "").trim()
: displayText;
body = (
<div className="st-body">
{shown && <div className="st-user-text">{shown}</div>}
{images.map((src) => (
<img key={src} className="st-user-image" src={src} alt="Uploaded image" loading="lazy" />
))}
</div>
);
} else if (variant === "agent") {
body = <Prose html={renderMarkdown(displayText)} />;
} else {
body = <div className="st-body" dangerouslySetInnerHTML={{ __html: renderInlineMarkdown(displayText) }} />;
}
return (
<Copyable className={`st-row st-row-${variant}`} text={displayText} label={label} data-row-id={row.id}>
{body}
</Copyable>
);
}
// ---------------------------------------------------------------------------
// tool rows
// ---------------------------------------------------------------------------
function ToolDetailBlock({ label, text }: { label: string; text: string }) {
return (
<div className="st-tool-detail">
<div className="st-tool-detail-label">{label}</div>
<Copyable as="pre" text={text} label={label.toLowerCase()}>
{text}
</Copyable>
</div>
);
}
function ContentBlockView({ block }: { block: AcpContentBlock }) {
if (block.type === "text") {
if (!block.text) return null;
return (
<Copyable className="st-tool-text" text={block.text} label="output">
{block.text}
</Copyable>
);
}
if (block.type === "image") {
const source = safeMediaSource(block.mimeType, block.data, "image");
if (source) return <img className="st-tool-image" src={source} alt="Tool output" loading="lazy" />;
return <ToolDetailBlock label="Image" text={block.uri ?? block.mimeType ?? "Unavailable"} />;
}
if (block.type === "audio") {
const source = safeMediaSource(block.mimeType, block.data, "audio");
if (source) return <audio className="st-tool-audio" controls src={source} />;
return <ToolDetailBlock label="Audio" text={block.mimeType ?? "Unavailable"} />;
}
if (block.type === "resource_link") {
return (
<div className="st-tool-resource">
<span className="st-tool-resource-name">{block.title ?? block.name ?? "Resource"}</span>
{block.uri && (
<Copyable as="code" className="st-tool-resource-uri" text={block.uri} label="resource URI">
{block.uri}
</Copyable>
)}
{block.description && <span className="st-tool-resource-description">{block.description}</span>}
</div>
);
}
const resource = block.resource;
if (typeof resource?.text === "string") {
return (
<ToolDetailBlock label={resource.uri ? `Resource · ${resource.uri}` : "Resource"} text={resource.text} />
);
}
const printed = resource === undefined ? "" : JSON.stringify(resource, null, 2);
return printed ? <ToolDetailBlock label="Resource" text={printed} /> : null;
}
function ToolBody({ tool, content }: { tool: AcpToolCallLike; content: AcpToolCallContent[] }) {
const payload = toolPayload(tool, content);
const hasRaw = payload.raw.input !== null || payload.raw.output !== null;
return (
<>
{content.map((block, index) => {
if (block.type === "content") return <ContentBlockView key={index} block={block.content} />;
if (block.type === "diff") return <DiffView key={index} diff={block} />;
return (
<div key={index} className="st-tool-terminal">
{block.terminalId ? `Terminal · ${block.terminalId}` : "Embedded terminal"}
</div>
);
})}
{payload.details.map((detail, index) => (
<ToolDetailBlock key={`${detail.label}-${index}`} label={detail.label} text={detail.text} />
))}
{payload.exitCode !== null && (
<div className="st-tool-fact">
<span className="st-tool-fact-label">Exit code</span>
<code className={payload.exitCode === 0 ? "st-tool-exit-ok" : "st-tool-exit-failed"}>
{payload.exitCode}
</code>
</div>
)}
{hasRaw && (
<details className="st-tool-raw">
<summary>Raw JSON</summary>
<div className="st-tool-raw-body">
{payload.raw.input !== null && <ToolDetailBlock label="Input" text={payload.raw.input} />}
{payload.raw.output !== null && <ToolDetailBlock label="Output" text={payload.raw.output} />}
</div>
</details>
)}
</>
);
}
/** True when a tool call has anything at all to disclose — a head with nothing
* behind it stays a plain line rather than an empty disclosure. */
function hasToolBody(tool: AcpToolCallLike, content: AcpToolCallContent[]): boolean {
if (content.length > 0) return true;
const payload = toolPayload(tool, content);
return payload.details.length > 0 || payload.exitCode !== null || payload.raw.input !== null || payload.raw.output !== null;
}
export interface ToolRowProps {
row: Row;
expanded: boolean;
onExpandedChange(expanded: boolean): void;
}
export function ToolRow({ row, expanded, onExpandedChange }: ToolRowProps) {
const tool = (row.data ?? {}) as AcpToolCallLike;
const content = tool.content ?? [];
const hasDiff = content.some((c) => c.type === "diff");
const { label, arg } = splitToolTitle(tool.title, tool.name, tool.kind);
const meta = statusMeta(tool.status, hasDiff);
const metaParts: string[] = [];
if (hasDiff) metaParts.push(toolDiffMeta(content));
if (meta) metaParts.push(meta.text);
const disclosable = hasToolBody(tool, content);
const open = expanded && disclosable;
return (
<div className="st-row st-row-tool" data-row-id={row.id}>
<div className="st-tool">
<button
type="button"
className="st-tool-head"
aria-expanded={open}
onClick={() => disclosable && onExpandedChange(!open)}
>
<ToolIcon kind={tool.kind} />
<span className="st-tool-name">{label}</span>
<span className="st-tool-arg" title={arg}>
{arg}
</span>
{metaParts.length > 0 && (
<span className={meta ? `st-tool-meta st-tool-meta-${meta.kind}` : "st-tool-meta"}>
{metaParts.join(" · ")}
</span>
)}
</button>
{disclosable && (
<div
className={open ? "st-tool-out st-disclosure is-expanded" : "st-tool-out st-disclosure"}
aria-hidden={!open}
inert={!open}
>
<div className="st-disclosure-clip">
<div className="st-disclosure-inner">
{/* Built only once opened: a long transcript would otherwise
render every tool's whole output up front. */}
{open && <ToolBody tool={tool} content={content} />}
</div>
</div>
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// notices and turn footers
// ---------------------------------------------------------------------------
function compactNumber(value: number): string {
if (Math.abs(value) < 1_000) return String(value);
if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}k`;
return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}m`;
}
export function TurnMetaRow({ meta }: { meta: TurnMeta }) {
const parts = [formatElapsed(meta.elapsedMs)];
if (typeof meta.contextDelta === "number") {
const sign = meta.contextDelta > 0 ? "+" : "";
parts.push(`${sign}${compactNumber(meta.contextDelta)} context tokens`);
} else if (typeof meta.contextTokens === "number") {
parts.push(`${compactNumber(meta.contextTokens)} context`);
}
if (meta.model) parts.push(meta.model);
if (meta.outcome !== "completed") parts.push(meta.outcome);
const text = parts.join(" · ");
return (
<Copyable
className={`st-turn-meta st-turn-meta-${meta.outcome}`}
text={text}
label="turn details"
aria-label={`Turn ${meta.outcome}`}
>
<span className="st-turn-meta-dot" aria-hidden="true" />
<span>{text}</span>
</Copyable>
);
}
/** The floor presentation docs/STRUCTURED-VIEW.md allows for a `Notice` the
* client has nothing better to do with: an opaque, collapsed line. */
export function NoticeRow({ row }: { row: Row }) {
const text = `[${row.kind}]`;
return (
<Copyable className="st-row st-row-notice" text={text} label="notice" data-row-id={row.id}>
<div className="st-body">{text}</div>
</Copyable>
);
}
// ---------------------------------------------------------------------------
// dispatch
// ---------------------------------------------------------------------------
export interface TranscriptRowProps {
row: Row;
/** This row is the one live approval (docs/STRUCTURED-VIEW.md's nonce
* rules). Every other approval row is history. */
pendingApproval?: boolean;
onDecide?: (decision: StructuredApprovalDecision) => Promise<void>;
expanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
imageUrls?: Map<string, string>;
}
/** One transcript row, or `null` for the rows the web client omits (`plan`,
* and the state-only notices it consumes for `/` autocomplete). */
export function TranscriptRow({
row,
pendingApproval = false,
onDecide,
expanded = false,
onExpandedChange,
imageUrls,
}: TranscriptRowProps) {
switch (row.kind) {
case "user":
return <ContentRow row={row} variant="user" imageUrls={imageUrls} />;
case "agent_text":
return <ContentRow row={row} variant="agent" />;
case "agent_thought":
return <ContentRow row={row} variant="thought" />;
case "tool_call":
case "tool_diff":
return (
<ToolRow row={row} expanded={expanded} onExpandedChange={(next) => onExpandedChange?.(next)} />
);
case "approval": {
const request = (row.data ?? {}) as { toolCall?: AcpToolCallLike; options?: AcpPermissionOption[] };
return (
<ApprovalView
request={request}
pending={pendingApproval}
onDecide={onDecide ?? (() => Promise.resolve())}
/>
);
}
case "plan":
return null;
case "notice": {
if (isStateOnlyNotice(row)) return null;
const meta = turnMeta(row);
return meta ? <TurnMetaRow meta={meta} /> : <NoticeRow row={row} />;
}
default:
return <NoticeRow row={row} />;
}
}
+13
View File
@@ -0,0 +1,13 @@
export { StructuredPane, type StructuredPaneHandle, type StructuredPaneProps } from "./StructuredPane.js";
export { TranscriptRow, type TranscriptRowProps } from "./TranscriptRow.js";
export { DiffView, type DiffViewProps } from "./DiffView.js";
export { ApprovalView, type ApprovalViewProps } from "./ApprovalView.js";
export { ToolGroup, type ToolGroupProps } from "./ToolGroup.js";
export { ToolIcon, toolGlyph, type ToolIconProps } from "./ToolIcon.js";
export { Composer, type ComposerProps } from "./Composer.js";
export {
useStructuredSession,
type StructuredSession,
type StructuredSocketFactory,
type StructuredStatus,
} from "./useStructuredSession.js";
+252
View File
@@ -0,0 +1,252 @@
import { describe, expect, it } from "vitest";
import type { ReducedState, TranscriptRow } from "../../types.js";
import {
activityGroupsByTurn,
applyCommand,
commandsFromTranscript,
commandTokenAt,
contentBlockText,
filterCommands,
isStateOnlyNotice,
offeredDecisions,
orderConfigOptions,
pendingApprovalRowId,
splitToolTitle,
statusMeta,
toolDiffMeta,
toolKindLabel,
transcriptItems,
turnMeta,
} from "./model.js";
function row(id: string, kind: TranscriptRow["kind"], data: unknown = {}): TranscriptRow {
return { id, kind, data };
}
function tool(id: string): TranscriptRow {
return row(id, "tool_call", { title: `Read(${id})`, status: "completed" });
}
function text(id: string, kind: "user" | "agent_text" | "agent_thought", body: string): TranscriptRow {
return row(id, kind, { content: { type: "text", text: body } });
}
const idle: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
describe("row taxonomy", () => {
it("splits an adapter's Name(argument) title, and falls back to the kind", () => {
expect(splitToolTitle("Read(src/main.ts)", null, "read")).toEqual({ label: "Read", arg: "src/main.ts" });
expect(splitToolTitle("something opaque", null, "search")).toEqual({
label: "Search",
arg: "something opaque",
});
expect(splitToolTitle(undefined, null, undefined)).toEqual({ label: "Tool", arg: "" });
});
it("labels an unknown tool kind rather than leaving it blank", () => {
expect(toolKindLabel("execute")).toBe("Execute");
expect(toolKindLabel("no_such_kind")).toBe("Tool");
expect(toolKindLabel(undefined)).toBe("Tool");
});
it("names non-text content blocks instead of dropping them", () => {
expect(contentBlockText({ type: "text", text: "hi" })).toBe("hi");
expect(contentBlockText({ type: "image" })).toBe("[image]");
expect(contentBlockText({ type: "resource_link", name: "spec.md" })).toBe("[resource: spec.md]");
expect(contentBlockText(undefined)).toBe("");
});
it("counts a tool call's diff lines", () => {
expect(toolDiffMeta([{ type: "diff", path: "a.ts", oldText: "one\ntwo\n", newText: "one\nTWO\nthree\n" }]))
.toBe("+2 −1");
expect(toolDiffMeta([])).toBe("+0 −0");
});
it("shows a status only while it is worth showing", () => {
expect(statusMeta("in_progress", false)).toEqual({ text: "running", kind: "running" });
expect(statusMeta("failed", true)).toEqual({ text: "failed", kind: "failed" });
expect(statusMeta("pending", true)).toBeNull(); // the diff counts already say it
expect(statusMeta("completed", false)).toBeNull();
});
});
describe("notices", () => {
it("reads a maestroTurnMeta footer, and only a well-formed one", () => {
const meta = { elapsedMs: 4200, outcome: "completed", model: "sonnet" };
expect(turnMeta(row("n1", "notice", { maestroTurnMeta: meta }))).toEqual(meta);
expect(turnMeta(row("n2", "notice", { maestroTurnMeta: { outcome: "completed" } }))).toBeNull();
expect(turnMeta(row("n3", "notice", { sessionUpdate: "session_info_update" }))).toBeNull();
});
it("marks the state-only notices the client consumes rather than shows", () => {
expect(isStateOnlyNotice(row("n1", "notice", { sessionUpdate: "available_commands_update" }))).toBe(true);
expect(isStateOnlyNotice(row("n2", "notice", { sessionUpdate: "session_info_update" }))).toBe(true);
expect(isStateOnlyNotice(row("n3", "notice", { sessionUpdate: "something_else" }))).toBe(false);
});
});
describe("approvals", () => {
it("offers only decisions the request actually backs", () => {
expect(
offeredDecisions([
{ optionId: "a", name: "Allow once", kind: "allow_once" },
{ optionId: "r", name: "Reject", kind: "reject_once" },
]),
).toEqual(["allow_once", "deny"]);
expect(offeredDecisions([{ optionId: "a", name: "Always", kind: "allow_always" }])).toEqual([
"allow_for_session",
]);
expect(offeredDecisions([])).toEqual([]);
});
it("points a live pending approval at the newest approval row", () => {
const rows = [row("approval:1", "approval"), tool("tool:1"), row("approval:2", "approval")];
const pending: ReducedState = {
...idle,
pending_approval: { nonce: "n", request: {} as never },
};
expect(pendingApprovalRowId(rows, pending)).toBe("approval:2");
expect(pendingApprovalRowId(rows, idle)).toBeNull();
});
});
describe("activity grouping", () => {
it("groups a turn's tools once there are two of them", () => {
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2")];
const groups = activityGroupsByTurn(rows, null, false);
expect([...groups.keys()]).toEqual(["turn:user:1"]);
expect(groups.get("turn:user:1")).toMatchObject({ toolCount: 2 });
});
it("leaves a single tool call standalone", () => {
const rows = [text("user:1", "user", "go"), tool("tool:1")];
expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
});
it("absorbs intermediate narration but keeps the final answer outside", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
text("agent:1", "agent_text", "working"),
tool("tool:2"),
text("agent:2", "agent_text", "done"),
];
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
expect(group.toolCount).toBe(2);
});
it("keeps the newest thought outside while the turn is still live", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
tool("tool:2"),
text("thought:1", "agent_thought", "hmm"),
text("thought:2", "agent_thought", "aha"),
];
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1"]);
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
});
it("absorbs a resolved approval, never the pending one", () => {
const rows = [
text("user:1", "user", "go"),
tool("tool:1"),
row("approval:1", "approval"),
tool("tool:2"),
row("approval:2", "approval"),
tool("tool:3"),
];
const group = activityGroupsByTurn(rows, "approval:2", false).get("turn:user:1")!;
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "approval:1", "tool:2", "tool:3"]);
});
});
describe("transcript render list", () => {
it("renders a group once, at its first member's position", () => {
const rows = [text("user:1", "user", "go"), tool("tool:1"), tool("tool:2"), text("agent:1", "agent_text", "done")];
const items = transcriptItems(rows, null, false);
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual([
"user:1",
"turn:user:1",
"agent:1",
]);
});
it("omits plan rows, state-only notices and standalone resolved approvals", () => {
const rows = [
text("user:1", "user", "go"),
row("plan", "plan", { entries: [] }),
row("notice:1", "notice", { sessionUpdate: "available_commands_update", availableCommands: [] }),
row("approval:1", "approval"),
text("agent:1", "agent_text", "done"),
];
const items = transcriptItems(rows, null, false);
expect(items.map((item) => (item.type === "row" ? item.row.id : item.id))).toEqual(["user:1", "agent:1"]);
});
it("keeps a turn footer only while it is the final item", () => {
const footer = row("turn_meta:1", "notice", { maestroTurnMeta: { elapsedMs: 10, outcome: "completed" } });
expect(transcriptItems([text("user:1", "user", "go"), footer], null, false)).toHaveLength(2);
const later = [text("user:1", "user", "go"), footer, text("user:2", "user", "again")];
expect(transcriptItems(later, null, false).map((i) => (i.type === "row" ? i.row.id : i.id))).toEqual([
"user:1",
"user:2",
]);
});
});
describe("composer commands", () => {
const notice = row("notice:9", "notice", {
sessionUpdate: "available_commands_update",
availableCommands: [
{ name: "compact", description: "Compact the\n conversation" },
{ name: "cost", description: "Show cost", input: { hint: "[period]" } },
{ name: 42 },
],
});
it("takes the newest available_commands_update, skipping malformed entries", () => {
const stale = row("notice:1", "notice", {
sessionUpdate: "available_commands_update",
availableCommands: [{ name: "old", description: "gone" }],
});
expect(commandsFromTranscript([stale, notice]).map((c) => c.name)).toEqual(["compact", "cost"]);
expect(commandsFromTranscript([])).toEqual([]);
});
it("only matches a /token under the caret", () => {
expect(commandTokenAt("/comp", 5, 5)).toEqual({ start: 0, end: 5, query: "comp" });
expect(commandTokenAt("hello /co there", 9, 9)).toEqual({ start: 6, end: 9, query: "co" });
expect(commandTokenAt("no slash", 3, 3)).toBeNull();
expect(commandTokenAt("/comp", 2, 5)).toBeNull(); // a selection is not a caret
});
it("ranks prefix matches ahead of substring matches", () => {
const commands = commandsFromTranscript([notice]);
expect(filterCommands(commands, "co").map((c) => c.name)).toEqual(["compact", "cost"]);
expect(filterCommands(commands, "act").map((c) => c.name)).toEqual(["compact"]);
});
it("replaces only the token under the caret", () => {
const token = commandTokenAt("say /co now", 7, 7)!;
expect(applyCommand("say /co now", token, "compact")).toEqual({ value: "say /compact now", cursor: 13 });
const trailing = commandTokenAt("/co", 3, 3)!;
expect(applyCommand("/co", trailing, "compact")).toEqual({ value: "/compact ", cursor: 9 });
});
});
describe("session config", () => {
it("orders the menu by category, unknown categories last", () => {
const options = [
{ id: "d", category: null },
{ id: "b", category: "thought_level" },
{ id: "a", category: "model" },
{ id: "c", category: "mode" },
];
expect(orderConfigOptions(options).map((o) => o.id)).toEqual(["a", "b", "c", "d"]);
});
});
+369
View File
@@ -0,0 +1,369 @@
// Pure transcript logic for the structured pane (#246) — the decisions
// docs/STRUCTURED-VIEW.md leaves to a client, kept out of the components so
// they can be tested without a DOM or a socket. Ported from structured.ts's
// private helpers and the parts of structuredRender.ts that were data, not
// markup.
import type {
AcpContentBlock,
AcpPermissionOption,
AcpToolCallContent,
ReducedState,
StructuredApprovalDecision,
TranscriptRow,
TranscriptRowKind,
TurnMeta,
} from "../../types.js";
import { computeDiffLines, diffStats } from "../../structuredDiff.js";
/** Human label behind a `ToolKind`. An unrecognised/absent kind is "Tool" —
* never a blank label. */
const KIND_LABEL: Record<string, string> = {
read: "Read",
edit: "Edit",
delete: "Delete",
move: "Move",
search: "Search",
execute: "Execute",
think: "Think",
fetch: "Fetch",
switch_mode: "Switch mode",
};
export function toolKindLabel(kind: string | undefined): string {
return (kind && KIND_LABEL[kind]) || "Tool";
}
/** Split a title into `{label, arg}` when it follows the adapter's own
* `Name(argument)` convention; otherwise the whole title is the arg and the
* tool's `kind` supplies a generic label. Never guesses at a shape the data
* doesn't actually show. */
export function splitToolTitle(
title: string | undefined,
name: string | null | undefined,
kind: string | undefined,
): { label: string; arg: string } {
const label = name ?? toolKindLabel(kind);
if (!title) return { label, arg: "" };
const m = /^(\S+)\(([\s\S]*)\)$/.exec(title);
if (m) return { label: name ?? m[1], arg: m[2] };
return { label, arg: title };
}
/** Content-block text, or a labelled placeholder for anything that isn't
* text — never dropped silently, never fabricated. */
export function contentBlockText(block: AcpContentBlock | undefined): string {
if (!block) return "";
switch (block.type) {
case "text":
return block.text;
case "image":
return "[image]";
case "audio":
return "[audio]";
case "resource_link":
return `[resource: ${block.name ?? block.uri ?? "linked"}]`;
case "resource":
return "[resource]";
default:
return "";
}
}
/** `+added −removed` across every diff block in a tool call's content — real
* derived data, never a fabricated duration. */
export function toolDiffMeta(content: AcpToolCallContent[]): string {
let added = 0;
let removed = 0;
for (const block of content) {
if (block.type !== "diff") continue;
const stats = diffStats(computeDiffLines(block.oldText, block.newText));
added += stats.added;
removed += stats.removed;
}
return `+${added} −${removed}`;
}
export function statusMeta(
status: string | undefined,
hasDiff: boolean,
): { text: string; kind: string } | null {
if (status === "in_progress") return { text: "running", kind: "running" };
if (status === "failed") return { text: "failed", kind: "failed" };
if (status === "pending" && !hasDiff) return { text: "pending", kind: "pending" };
return null;
}
/** The `maestroTurnMeta` footer a `Notice` row carries when it is a completed,
* cancelled or failed turn's durable footer — `null` for every other notice. */
export function turnMeta(row: TranscriptRow): TurnMeta | null {
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return null;
const value = (row.data as { maestroTurnMeta?: unknown }).maestroTurnMeta;
if (!value || typeof value !== "object") return null;
const meta = value as Partial<TurnMeta>;
if (typeof meta.elapsedMs !== "number" || typeof meta.outcome !== "string") return null;
return meta as TurnMeta;
}
/** Notices the web client consumes as state and omits from the visible
* transcript (docs/STRUCTURED-VIEW.md, "A client decides"). */
export function isStateOnlyNotice(row: TranscriptRow): boolean {
if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return false;
const update = (row.data as { sessionUpdate?: string }).sessionUpdate;
return update === "available_commands_update" || update === "session_info_update";
}
/** Which decisions a permission request actually backs, by the
* `PermissionOptionKind` values it offers (docs/STRUCTURED-VIEW.md's
* "Decision mapping"). A decision with no matching option resolves
* `Cancelled` server-side regardless of what the client sent — offering its
* button would let the operator "allow" something that silently no-ops. */
export function offeredDecisions(options: AcpPermissionOption[]): StructuredApprovalDecision[] {
const kinds = new Set(options.map((o) => o.kind));
const decisions: StructuredApprovalDecision[] = [];
if (kinds.has("allow_once")) decisions.push("allow_once");
if (kinds.has("allow_always")) decisions.push("allow_for_session");
if (kinds.has("reject_once") || kinds.has("reject_always")) decisions.push("deny");
return decisions;
}
/** The row id a live `pending_approval` names: the newest `Approval` row.
* Only one approval is ever live (docs/STRUCTURED-VIEW.md, "One live
* approval at a time"), so the last one is the pending one. */
export function pendingApprovalRowId(rows: TranscriptRow[], state: ReducedState): string | null {
return state.pending_approval ? lastRowIdOfKind(rows, "approval") : null;
}
export function lastRowIdOfKind(rows: TranscriptRow[], kind: TranscriptRowKind): string | null {
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].kind === kind) return rows[i].id;
return null;
}
export function turnIsActive(state: ReducedState): boolean {
return state.turn.responding || state.turn.thinking;
}
export interface ActivityGroup {
/** Header label count — tool rows only, never absorbed text. */
toolCount: number;
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
rows: TranscriptRow[];
}
/**
* Group tool rows (and intermediate agent text / resolved approvals between
* them) by turn. An agent_text/agent_thought row is "intermediate" when a tool
* row follows it in the same turn — it gets absorbed into the group so the
* collapsed block preserves chronological order. Resolved approval rows (not
* the currently pending one) are absorbed the same way. The *last* agent text
* with no subsequent tool stays standalone (the prominent final answer).
*
* Returns groups keyed by `turn:<userId>`.
*/
export function activityGroupsByTurn(
rows: TranscriptRow[],
pendingApprovalId: string | null,
turnActive: boolean,
): Map<string, ActivityGroup> {
// First: count tools per turn to know which turns qualify for grouping.
const toolCounts = new Map<string, number>();
let turnId = "before-first-message";
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
if (row.kind !== "tool_call" && row.kind !== "tool_diff") continue;
const gid = `turn:${turnId}`;
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
}
// Second: build groups with interleaved text for turns with 2+ tools.
const groups = new Map<string, ActivityGroup>();
turnId = "before-first-message";
// Pending rows that might be intermediate (not yet confirmed by a following
// tool). If no tool follows, they stay standalone.
let pending: TranscriptRow[] = [];
let currentGid = `turn:${turnId}`;
for (const row of rows) {
if (row.kind === "user") {
turnId = row.id;
currentGid = `turn:${turnId}`;
pending = [];
continue;
}
const count = toolCounts.get(currentGid) ?? 0;
if (count < 2) continue;
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
if (isIntermediateText || isResolvedApproval) {
pending.push(row);
continue;
}
if (row.kind === "tool_call" || row.kind === "tool_diff") {
const group = groups.get(currentGid) ?? { toolCount: count, rows: [] };
// Flush any pending rows — a tool follows, so they're intermediate.
group.rows.push(...pending);
pending = [];
group.rows.push(row);
groups.set(currentGid, group);
}
}
// A final agent text is the answer, so it stays outside. Thought rows are
// working commentary, though: when a turn already has a disclosure they
// belong with its activity even if the next tool call has not arrived yet.
// While the turn is still live the most recent one stays outside as visible
// narration (docs/STRUCTURED-VIEW.md), and is absorbed when the turn ends.
const lastGroup = groups.get(currentGid);
if (lastGroup && pending.length > 0 && pending.every((row) => row.kind === "agent_thought")) {
const absorb = turnActive ? pending.slice(0, -1) : pending;
lastGroup.rows.push(...absorb);
}
return groups;
}
/** One entry of the flattened render list: either a standalone row or a whole
* activity group. */
export type TranscriptItem =
| { type: "row"; row: TranscriptRow }
| { type: "group"; id: string; group: ActivityGroup };
/**
* The transcript in render order: rows, with each turn's tool activity folded
* into a single group item where one exists. Resolved approvals not absorbed
* into a group are dropped — the tool row itself already identifies the
* action. `plan` rows and state-only notices are dropped too (both clients
* omit `Plan` from the transcript). A turn footer only survives as the final
* item, so old footers stop reading like transcript content.
*/
export function transcriptItems(
rows: TranscriptRow[],
pendingApprovalId: string | null,
turnActive: boolean,
): TranscriptItem[] {
const groups = activityGroupsByTurn(rows, pendingApprovalId, turnActive);
const grouped = new Set<string>();
for (const group of groups.values()) for (const row of group.rows) grouped.add(row.id);
const items: TranscriptItem[] = [];
let turnId = "before-first-message";
const emitted = new Set<string>();
for (const row of rows) {
if (row.kind === "user") turnId = row.id;
const groupId = `turn:${turnId}`;
const group = groups.get(groupId);
if (group && grouped.has(row.id)) {
// The group renders once, at its first member's position.
if (!emitted.has(groupId)) {
emitted.add(groupId);
items.push({ type: "group", id: groupId, group });
}
continue;
}
if (row.kind === "plan") continue;
if (row.kind === "notice" && isStateOnlyNotice(row)) continue;
// A resolved approval outside a group adds nothing the tool row does not
// already say. A pending one always renders.
if (row.kind === "approval" && row.id !== pendingApprovalId) continue;
items.push({ type: "row", row });
}
return items.filter(
(item, index) =>
!(item.type === "row" && turnMeta(item.row) !== null) || index === items.length - 1,
);
}
// ---------------------------------------------------------------------------
// composer: `/` command autocomplete off `available_commands_update` notices
// ---------------------------------------------------------------------------
export interface AvailableCommand {
name: string;
description: string;
input?: { hint?: string };
}
/** The live command list is whatever the most recent
* `available_commands_update` notice carries (docs/STRUCTURED-VIEW.md). */
export function commandsFromTranscript(rows: TranscriptRow[]): AvailableCommand[] {
for (let i = rows.length - 1; i >= 0; i--) {
if (rows[i].kind !== "notice") continue;
const data = rows[i].data as { sessionUpdate?: string; availableCommands?: unknown[] } | undefined;
if (data?.sessionUpdate !== "available_commands_update") continue;
return (data.availableCommands ?? []).flatMap((value) => {
if (!value || typeof value !== "object") return [];
const command = value as Partial<AvailableCommand>;
if (typeof command.name !== "string" || typeof command.description !== "string") return [];
return [{ name: command.name, description: command.description, input: command.input }];
});
}
return [];
}
export interface CommandToken {
start: number;
end: number;
query: string;
}
/** The whitespace-delimited token under the caret, when it is a `/command`
* being typed. Completing one replaces only that token. */
export function commandTokenAt(
value: string,
selectionStart: number,
selectionEnd: number,
): CommandToken | null {
if (selectionStart !== selectionEnd) return null;
let start = selectionStart;
while (start > 0 && !/\s/.test(value[start - 1])) start--;
let end = selectionStart;
while (end < value.length && !/\s/.test(value[end])) end++;
const match = /^\/([^\s/]*)$/.exec(value.slice(start, selectionStart));
return match ? { start, end, query: match[1].toLocaleLowerCase() } : null;
}
/** Substring match, prefix matches first, then alphabetical. */
export function filterCommands(commands: AvailableCommand[], query: string): AvailableCommand[] {
return commands
.filter((command) => command.name.toLocaleLowerCase().includes(query))
.sort((a, b) => {
const aStarts = a.name.toLocaleLowerCase().startsWith(query);
const bStarts = b.name.toLocaleLowerCase().startsWith(query);
return aStarts === bStarts ? a.name.localeCompare(b.name) : aStarts ? -1 : 1;
});
}
/** Replace the token under the caret with `/name`, returning the new draft and
* where the caret lands. A trailing space is added only when the completion
* ends the draft. */
export function applyCommand(
value: string,
token: CommandToken,
name: string,
): { value: string; cursor: number } {
const before = value.slice(0, token.start);
const after = value.slice(token.end);
let replacement = `/${name}`;
let cursor = before.length + replacement.length;
if (after.length === 0) {
replacement += " ";
cursor++;
} else if (/^[ \t]/.test(after)) {
cursor++;
}
return { value: before + replacement + after, cursor };
}
// ---------------------------------------------------------------------------
// session config menu
// ---------------------------------------------------------------------------
/** Category order the config menu sorts by; anything else lands after. */
const CONFIG_CATEGORY_ORDER = ["model", "thought_level", "mode", "model_config"];
export function orderConfigOptions<T extends { category?: string | null }>(options: T[]): T[] {
return [...options].sort((a, b) => {
const rank = (category?: string | null) => CONFIG_CATEGORY_ORDER.indexOf(category ?? "");
const aRank = rank(a.category);
const bRank = rank(b.category);
return (aRank < 0 ? 99 : aRank) - (bRank < 0 ? 99 : bRank);
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,112 @@
// What a tool row shows below its head, derived from `rawInput`/`rawOutput`.
// The adapters emit a handful of recognisable payload shapes (a shell command,
// an MCP call, a search, a plain function call); anything else falls through to
// the raw JSON disclosure rather than being guessed at.
import type { AcpContentBlock, AcpToolCallContent, AcpToolCallLike } from "../../types.js";
/** One labelled `<pre>` block under a tool head. */
export interface ToolDetail {
label: string;
text: string;
}
export interface ToolPayload {
details: ToolDetail[];
/** A shell command's exit code, when the output carried one. */
exitCode: number | null;
raw: { input: string | null; output: string | null };
}
function printable(value: unknown): string | null {
if (value === undefined || value === null) return null;
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function jsonPrintable(value: unknown): string | null {
if (value === undefined) return null;
try {
return JSON.stringify(value, null, 2) ?? null;
} catch {
return printable(value);
}
}
function record(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
/** Push a detail unless it is empty, or repeats text the content blocks above
* already show verbatim. */
function push(details: ToolDetail[], label: string, value: unknown, duplicates?: Set<string>): void {
const text = printable(value);
if (text !== null && text !== "" && !duplicates?.has(text)) details.push({ label, text });
}
/** The text of every `content` block, so a `rawOutput` echoing it is not shown
* a second time. */
function contentTexts(content: AcpToolCallContent[]): Set<string> {
return new Set(
content
.filter((block): block is Extract<AcpToolCallContent, { type: "content" }> => block.type === "content")
.filter((block) => block.content.type === "text")
.map((block) => (block.content as Extract<AcpContentBlock, { type: "text" }>).text),
);
}
export function toolPayload(tool: AcpToolCallLike, content: AcpToolCallContent[]): ToolPayload {
const details: ToolDetail[] = [];
let exitCode: number | null = null;
const input = record(tool.rawInput);
const texts = contentTexts(content);
if (input) {
if (typeof input.command === "string") {
details.push({ label: "Command", text: input.command });
push(details, "Working directory", input.cwd);
const result = record(tool.rawOutput);
if (result) {
push(details, "Output", result.formatted_output ?? result.stdout ?? result.output, texts);
if (typeof result.exit_code === "number") exitCode = result.exit_code;
}
} else if (typeof input.server === "string" && typeof input.tool === "string" && "arguments" in input) {
push(details, "Arguments", input.arguments);
const result = record(tool.rawOutput);
if (result) {
push(details, "Result", result.result, texts);
push(details, "Error", result.error);
}
} else if (typeof input.query === "string" && (tool.kind === "search" || input.type === "webSearch")) {
details.push({ label: "Query", text: input.query });
push(details, "Action", input.action);
} else if ("arguments" in input) {
push(details, "Arguments", input.arguments);
const result = record(tool.rawOutput);
if (result) push(details, "Result", result.output, texts);
}
}
return {
details,
exitCode,
raw: { input: jsonPrintable(tool.rawInput), output: jsonPrintable(tool.rawOutput) },
};
}
/** A `data:` URL for an inline image/audio block, or `null` when the mime type
* is absent or does not name that family — never a URL built from an
* unvalidated string. */
export function safeMediaSource(
mimeType: string | undefined,
data: string | undefined,
family: "image" | "audio",
): string | null {
if (!mimeType || !data || !new RegExp(`^${family}/[a-z0-9.+-]+$`, "i").test(mimeType)) return null;
return `data:${mimeType};base64,${data}`;
}
@@ -0,0 +1,216 @@
// The `WS /api/structured/:id` half of the pane (#246). Every connect — fresh
// or reconnect — resends the whole transcript rather than resuming a cursor
// (docs/STRUCTURED-VIEW.md), so a snapshot replaces state wholesale instead of
// merging into what came before.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { structuredWsUrl } from "../../routes.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
import type {
ReducedState,
StructuredEvent,
TranscriptDelta,
TranscriptRow,
Uuid,
} from "../../types.js";
export type StructuredStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
/** Injectable so tests can drive the socket without a daemon — the same seam
* structured.ts used. */
export type StructuredSocketFactory = (sessionId: Uuid) => WebSocket;
const idleState = (): ReducedState => ({
turn: { responding: false, thinking: false, active_tool_calls: [] },
});
function applyDelta(rows: TranscriptRow[], delta: TranscriptDelta): TranscriptRow[] {
if (delta.type === "append") {
// A patch can arrive for a row this connection has never seen (its
// snapshot predates it); treating an unknown id as an append keeps the
// transcript complete rather than dropping the row.
const at = rows.findIndex((row) => row.id === delta.row.id);
return at === -1 ? [...rows, delta.row] : rows.map((row, i) => (i === at ? delta.row : row));
}
if (delta.type === "patch") {
const at = rows.findIndex((row) => row.id === delta.row.id);
return at === -1 ? [...rows, delta.row] : rows.map((row, i) => (i === at ? delta.row : row));
}
return rows.filter((row) => row.id !== delta.id);
}
export interface StructuredSession {
rows: TranscriptRow[];
state: ReducedState;
status: StructuredStatus;
/** A daemon-supplied close reason (code ≥ 4000), when there is one. */
detail: string | null;
/** The number of sockets this pane has opened, newest last. A submission
* that straddles a reconnect uses it to notice its acceptance may predate
* the current snapshot. */
socketGeneration: number;
/** Drop the socket and reconnect, so the next snapshot cannot predate a
* POST that has already been accepted. */
reconnect(): void;
}
export interface UseStructuredSessionOptions {
shouldReconnect?: (code: number) => boolean;
onClosed?: () => void;
socketFactory?: StructuredSocketFactory;
}
/** Application-defined close codes (daemon rejection) and the clean-close code
* — retrying either would only loop. */
function defaultShouldReconnect(code: number): boolean {
return code < 4000 && code !== 1000;
}
export function useStructuredSession(
sessionId: Uuid,
{
shouldReconnect = defaultShouldReconnect,
onClosed,
socketFactory = (id) => new WebSocket(structuredWsUrl(id)),
}: UseStructuredSessionOptions = {},
): StructuredSession {
const [rows, setRows] = useState<TranscriptRow[]>([]);
const [state, setState] = useState<ReducedState>(idleState);
const [status, setStatus] = useState<StructuredStatus>("connecting");
const [detail, setDetail] = useState<string | null>(null);
const [socketGeneration, setSocketGeneration] = useState(0);
// Read through refs so a changed callback never tears the socket down.
const shouldReconnectRef = useRef(shouldReconnect);
const onClosedRef = useRef(onClosed);
const factoryRef = useRef(socketFactory);
shouldReconnectRef.current = shouldReconnect;
onClosedRef.current = onClosed;
factoryRef.current = socketFactory;
const reconnectRef = useRef<() => void>(() => {});
useEffect(() => {
let disposed = false;
let socket: WebSocket | null = null;
let reconnectMs = INITIAL_RECONNECT_MS;
let timer: number | null = null;
const closeSocket = () => {
const existing = socket;
socket = null;
if (!existing) return;
existing.onopen = null;
existing.onmessage = null;
existing.onerror = null;
existing.onclose = null;
try {
existing.close();
} catch {
// ignore — an already-closing socket has nothing to close
}
};
const cancelTimer = () => {
if (timer === null) return;
window.clearTimeout(timer);
timer = null;
};
function connect(): void {
if (disposed) return;
cancelTimer();
closeSocket();
const current = factoryRef.current(sessionId);
socket = current;
setSocketGeneration((n) => n + 1);
current.onopen = () => {
if (disposed || socket !== current) return;
reconnectMs = INITIAL_RECONNECT_MS;
setDetail(null);
setStatus("open");
};
current.onmessage = (ev) => {
if (disposed || socket !== current) return;
if (typeof ev.data !== "string") return;
let msg: StructuredEvent;
try {
msg = JSON.parse(ev.data) as StructuredEvent;
} catch {
return; // not a structured event we understand
}
switch (msg.type) {
case "transcript_snapshot":
setRows([...msg.snapshot.rows]);
break;
case "reduced_state":
setState(msg.state);
break;
case "projection_update":
if (msg.transcript) {
const delta = msg.transcript;
setRows((previous) => applyDelta(previous, delta));
}
setState(msg.state);
break;
}
};
current.onerror = () => {
if (disposed || socket !== current) return;
setStatus("error");
};
current.onclose = (ev) => {
if (disposed || socket !== current) return;
socket = null;
const reason = ev.reason?.trim();
if (ev.code >= 4000 && reason) {
setDetail(reason);
setStatus("error");
return;
}
if (shouldReconnectRef.current(ev.code)) {
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
setStatus("reconnecting");
timer = window.setTimeout(() => {
timer = null;
if (disposed) return;
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
connect();
}, delay);
return;
}
setDetail(String(ev.code));
setStatus("closed");
onClosedRef.current?.();
};
}
reconnectRef.current = () => {
if (disposed) return;
setStatus("reconnecting");
connect();
};
setStatus("connecting");
connect();
return () => {
disposed = true;
reconnectRef.current = () => {};
cancelTimer();
closeSocket();
};
}, [sessionId]);
const reconnect = useCallback(() => reconnectRef.current(), []);
return useMemo(
() => ({ rows, state, status, detail, socketGeneration, reconnect }),
[rows, state, status, detail, socketGeneration, reconnect],
);
}
+134
View File
@@ -0,0 +1,134 @@
// React port of ../../attach.ts's `buildKeyBar` — the Termux-style special-key
// bar for the soft keyboard (Esc/Tab/Ctrl/Alt/cursor keys/symbols it hides
// behind a page switch). Ctrl/Alt are one-shot sticky modifiers: arming one
// folds into the very next key, tapped here or typed on the soft keyboard,
// then disarms.
import { useCallback, useRef, useState } from "react";
import { applyModsToChar, cursorSeq, tildeSeq } from "../../keys.js";
type Modifier = "ctrl" | "alt";
/** CSI final bytes: arrows A-D, Home `H`, End `F`. */
type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
type KeyDef =
| { label: string; mod: Modifier }
| { label: string; cursor: CursorFinal }
| { label: string; tilde: number }
| { label: string; char: string }
| { label: string; seq: string };
/**
* Row-major into an 8-column / 2-row grid (#7):
*
* Esc Tab Home ↑ End PgUp - /
* Ctrl Alt ← ↓ → PgDn | ~
*/
const KEY_DEFS: KeyDef[] = [
{ label: "Esc", seq: "\x1b" },
{ label: "Tab", seq: "\t" },
{ label: "Home", cursor: "H" },
{ label: "↑", cursor: "A" },
{ label: "End", cursor: "F" },
{ label: "PgUp", tilde: 5 },
{ label: "-", char: "-" },
{ label: "/", char: "/" },
{ label: "Ctrl", mod: "ctrl" },
{ label: "Alt", mod: "alt" },
{ label: "←", cursor: "D" },
{ label: "↓", cursor: "B" },
{ label: "→", cursor: "C" },
{ label: "PgDn", tilde: 6 },
{ label: "|", char: "|" },
{ label: "~", char: "~" },
];
/**
* Pending Ctrl/Alt state plus `consume`, which folds them into a soft-keyboard
* character before it reaches the PTY — used by the terminal's own `onData`
* handler, not by this component's own button clicks.
*/
export function useKeyBarModifiers() {
const armedRef = useRef<{ ctrl: boolean; alt: boolean }>({ ctrl: false, alt: false });
const [, setTick] = useState(0);
const rerender = useCallback(() => setTick((n) => n + 1), []);
const setMod = useCallback(
(mod: Modifier, on: boolean) => {
armedRef.current[mod] = on;
rerender();
},
[rerender],
);
const clearMods = useCallback(() => {
if (!armedRef.current.ctrl && !armedRef.current.alt) return;
armedRef.current = { ctrl: false, alt: false };
rerender();
}, [rerender]);
const consume = useCallback((data: string): string => {
const { ctrl, alt } = armedRef.current;
if ((ctrl || alt) && data.length === 1) {
const out = applyModsToChar(data, ctrl, alt);
armedRef.current = { ctrl: false, alt: false };
return out;
}
return data;
}, []);
return { armed: armedRef.current, setMod, clearMods, consume };
}
export interface KeyBarProps {
armed: { ctrl: boolean; alt: boolean };
onToggleMod(mod: Modifier): void;
onClearMods(): void;
onSend(data: string): void;
}
export function KeyBar({ armed, onToggleMod, onClearMods, onSend }: KeyBarProps) {
return (
<div
role="toolbar"
aria-label="Special keys"
className="flex flex-wrap gap-1 border-t border-white/10 bg-black/20 p-1"
>
{KEY_DEFS.map((def) => {
if ("mod" in def) {
return (
<button
key={def.label}
type="button"
aria-pressed={armed[def.mod]}
aria-label={`${def.label} modifier`}
className="min-w-8 rounded px-2 py-1 text-xs aria-pressed:bg-emerald-500/40"
onClick={() => onToggleMod(def.mod)}
>
{def.label}
</button>
);
}
const bytes = () => {
if ("cursor" in def) return cursorSeq(def.cursor, armed.ctrl, armed.alt);
if ("tilde" in def) return tildeSeq(def.tilde, armed.ctrl, armed.alt);
if ("char" in def) return applyModsToChar(def.char, armed.ctrl, armed.alt);
return def.seq;
};
return (
<button
key={def.label}
type="button"
className="min-w-8 rounded px-2 py-1 text-xs"
onClick={() => {
onSend(bytes());
onClearMods();
}}
>
{def.label}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,363 @@
// @vitest-environment happy-dom
import { cleanup, render } from "@testing-library/react";
import { createRef } from "react";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
import { attachWsUrl } from "../../routes.js";
import type { ClientControl } from "../../types.js";
class FakeTerminal {
static instances: FakeTerminal[] = [];
cols = 80;
rows = 24;
element: HTMLElement | undefined = undefined;
textarea: HTMLTextAreaElement | null = null;
disposed = false;
focus = vi.fn();
private dataCb: ((d: string) => void) | null = null;
constructor(public opts: unknown) {
FakeTerminal.instances.push(this);
}
loadAddon(): void {}
open(container: HTMLElement): void {
container.appendChild(document.createElement("div"));
}
onData(cb: (d: string) => void) {
this.dataCb = cb;
return { dispose: vi.fn() };
}
emitData(d: string): void {
this.dataCb?.(d);
}
onResize() {
return { dispose: vi.fn() };
}
onSelectionChange() {
return { dispose: vi.fn() };
}
write(_data: Uint8Array, cb?: () => void): void {
cb?.();
}
scrollToBottom(): void {}
resize(cols: number, rows: number): void {
this.cols = cols;
this.rows = rows;
}
clearTextureAtlas(): void {}
getSelection(): string {
return "";
}
dispose(): void {
this.disposed = true;
}
}
class FakeFitAddon {
fit = vi.fn();
}
class FakeWebglAddon {
static instances: FakeWebglAddon[] = [];
disposed = false;
private lossCb: (() => void) | null = null;
constructor() {
FakeWebglAddon.instances.push(this);
}
onContextLoss(cb: () => void): void {
this.lossCb = cb;
}
triggerLoss(): void {
this.lossCb?.();
}
dispose(): void {
this.disposed = true;
}
}
const policyEnabled = vi.fn(() => true);
const policyNoteLoss = vi.fn();
vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon }));
vi.mock("../../webglRenderer.js", () => ({
WebglPolicy: vi.fn().mockImplementation(() => ({
enabled: policyEnabled,
noteContextLoss: policyNoteLoss,
})),
}));
class FakeWebSocket {
static OPEN = 1;
static CONNECTING = 0;
static CLOSING = 2;
static CLOSED = 3;
static instances: FakeWebSocket[] = [];
readyState = FakeWebSocket.CONNECTING;
binaryType = "";
sent: (string | ArrayBufferView)[] = [];
onopen: (() => void) | null = null;
onmessage: ((ev: { data: unknown }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
constructor(public url: string) {
FakeWebSocket.instances.push(this);
}
send(data: string | ArrayBufferView): void {
this.sent.push(data);
}
close(): void {
this.readyState = FakeWebSocket.CLOSED;
}
open(): void {
this.readyState = FakeWebSocket.OPEN;
this.onopen?.();
}
drop(code = 1006, reason = ""): void {
this.readyState = FakeWebSocket.CLOSED;
this.onclose?.({ code, reason });
}
lastControl(): ClientControl {
return JSON.parse(this.sent.at(-1) as string) as ClientControl;
}
}
beforeAll(() => {
vi.stubGlobal(
"ResizeObserver",
class {
observe(): void {}
disconnect(): void {}
unobserve(): void {}
},
);
});
beforeEach(() => {
FakeTerminal.instances = [];
FakeWebglAddon.instances = [];
FakeWebSocket.instances = [];
policyEnabled.mockReturnValue(true);
policyEnabled.mockClear();
policyNoteLoss.mockClear();
vi.stubGlobal("WebSocket", FakeWebSocket);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
// Imported after the mocks above so it picks up the mocked xterm/webgl modules.
const { TerminalPane } = await import("./TerminalPane.js");
describe("TerminalPane lifecycle", () => {
it("opens an xterm terminal and connects to the session's attach socket on mount", () => {
render(<TerminalPane sessionId="s1" />);
expect(FakeTerminal.instances).toHaveLength(1);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(FakeWebSocket.instances[0].url).toBe(attachWsUrl("s1"));
});
it("sends the terminal size once the socket opens", () => {
render(<TerminalPane sessionId="s1" />);
const sock = FakeWebSocket.instances[0];
sock.open();
expect(sock.lastControl()).toEqual({ type: "Resize", cols: 80, rows: 24 });
});
it("disposes the terminal and closes the socket on unmount", () => {
const { unmount } = render(<TerminalPane sessionId="s1" />);
const term = FakeTerminal.instances[0];
const sock = FakeWebSocket.instances[0];
unmount();
expect(term.disposed).toBe(true);
expect(sock.readyState).toBe(FakeWebSocket.CLOSED);
});
it("reconnects to a fresh socket for a new sessionId", () => {
const { rerender } = render(<TerminalPane sessionId="s1" />);
rerender(<TerminalPane sessionId="s2" />);
expect(FakeWebSocket.instances).toHaveLength(2);
expect(FakeWebSocket.instances[1].url).toBe(attachWsUrl("s2"));
expect(FakeTerminal.instances[0].disposed).toBe(true);
});
});
describe("TerminalPane reconnect backoff", () => {
it("retries a dropped socket, doubling the delay from 500ms up to a 10s cap", () => {
vi.useFakeTimers();
render(<TerminalPane sessionId="s1" />);
const drop = () => FakeWebSocket.instances.at(-1)!.drop(1006, "");
const expectStillWaiting = (ms: number, countBefore: number) => {
vi.advanceTimersByTime(ms - 1);
expect(FakeWebSocket.instances).toHaveLength(countBefore);
vi.advanceTimersByTime(1);
expect(FakeWebSocket.instances).toHaveLength(countBefore + 1);
};
expect(FakeWebSocket.instances).toHaveLength(1);
drop();
expectStillWaiting(INITIAL_RECONNECT_MS, 1); // 500ms -> #2
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 2, 2); // 1000ms -> #3
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 4, 3); // 2000ms -> #4
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 8, 4); // 4000ms -> #5
drop();
expectStillWaiting(INITIAL_RECONNECT_MS * 16, 5); // 8000ms -> #6
drop();
expectStillWaiting(MAX_RECONNECT_MS, 6); // capped at 10s -> #7
drop();
expectStillWaiting(MAX_RECONNECT_MS, 7); // stays capped -> #8
});
it("resets the backoff once a reconnected socket opens", () => {
vi.useFakeTimers();
render(<TerminalPane sessionId="s1" />);
FakeWebSocket.instances[0].drop(1006, "");
vi.advanceTimersByTime(INITIAL_RECONNECT_MS);
FakeWebSocket.instances[1].open();
FakeWebSocket.instances[1].drop(1006, "");
vi.advanceTimersByTime(INITIAL_RECONNECT_MS - 1);
expect(FakeWebSocket.instances).toHaveLength(2);
vi.advanceTimersByTime(1);
expect(FakeWebSocket.instances).toHaveLength(3);
});
it("does not retry a clean close, and reports it closed", () => {
vi.useFakeTimers();
const onClosed = vi.fn();
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
FakeWebSocket.instances[0].drop(1000, "");
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(onClosed).toHaveBeenCalledOnce();
});
it("does not retry an application-level rejection, and writes it into the terminal", () => {
vi.useFakeTimers();
const onClosed = vi.fn();
render(<TerminalPane sessionId="s1" onClosed={onClosed} />);
const term = FakeTerminal.instances[0];
const writeSpy = vi.spyOn(term, "write");
FakeWebSocket.instances[0].drop(4004, "unknown session");
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(onClosed).not.toHaveBeenCalled();
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("unknown session"));
});
it("honors a caller-supplied shouldReconnect policy", () => {
vi.useFakeTimers();
const onClosed = vi.fn();
render(<TerminalPane sessionId="s1" shouldReconnect={() => false} onClosed={onClosed} />);
FakeWebSocket.instances[0].drop(1006, "");
vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(onClosed).toHaveBeenCalledOnce();
});
});
describe("TerminalPane WebGL context budget", () => {
it("acquires a WebGL context on mount, shared policy allowing", () => {
render(<TerminalPane sessionId="s1" />);
expect(policyEnabled).toHaveBeenCalled();
expect(FakeWebglAddon.instances).toHaveLength(1);
});
it("never creates a context once the shared budget is exhausted", () => {
policyEnabled.mockReturnValue(false);
render(<TerminalPane sessionId="s1" />);
expect(FakeWebglAddon.instances).toHaveLength(0);
});
it("releases its context on unmount, handing the budget back", () => {
const { unmount } = render(<TerminalPane sessionId="s1" />);
const addon = FakeWebglAddon.instances[0];
unmount();
expect(addon.disposed).toBe(true);
});
it("counts a context loss and re-acquires while still mounted", () => {
render(<TerminalPane sessionId="s1" />);
const first = FakeWebglAddon.instances[0];
first.triggerLoss();
expect(policyNoteLoss).toHaveBeenCalledOnce();
expect(first.disposed).toBe(true);
expect(FakeWebglAddon.instances).toHaveLength(2);
});
it("does not re-acquire a lost context once the budget is spent", () => {
render(<TerminalPane sessionId="s1" />);
const first = FakeWebglAddon.instances[0];
policyEnabled.mockReturnValue(false);
first.triggerLoss();
expect(FakeWebglAddon.instances).toHaveLength(1);
});
});
describe("TerminalPane imperative handle", () => {
it("sendInput writes bytes to the PTY only once the socket is open", () => {
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
render(<TerminalPane sessionId="s1" ref={ref} />);
const sock = FakeWebSocket.instances[0];
ref.current!.sendInput("nope");
expect(sock.sent).toHaveLength(0);
sock.open();
sock.sent.length = 0; // drop the resize control frame from open()
ref.current!.sendInput("hi");
expect(sock.sent).toHaveLength(1);
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("hi");
});
it("focusTerminal focuses the underlying xterm instance", () => {
const ref = createRef<import("./TerminalPane.js").TerminalPaneHandle>();
render(<TerminalPane sessionId="s1" ref={ref} />);
ref.current!.focusTerminal();
expect(FakeTerminal.instances[0].focus).toHaveBeenCalledOnce();
});
it("routes typed keystrokes through to the socket", () => {
render(<TerminalPane sessionId="s1" />);
const sock = FakeWebSocket.instances[0];
sock.open();
sock.sent.length = 0;
FakeTerminal.instances[0].emitData("q");
expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("q");
});
});
@@ -0,0 +1,362 @@
// React port of ../../attach.ts: an xterm terminal bound to the daemon's
// attach WebSocket, plus ../../webglRenderer.ts's shared GPU-context budget
// and ../../reconnect.ts's backoff schedule. Pane chrome (title, disconnect
// button, sessions drawer) stays out of scope here — this is the thin
// ref-based xterm wrapper the issue asked for; a header composes around it.
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
import { attachWsUrl } from "../../routes.js";
import type { ClientControl, Uuid } from "../../types.js";
import { WebglPolicy } from "../../webglRenderer.js";
import { KeyBar, useKeyBarModifiers } from "./KeyBar.js";
/** One budget for the whole page — every mounted `TerminalPane` shares it, the
* same reasoning as attach.ts's own module-level policy. */
const webglPolicy = new WebglPolicy();
export type TerminalStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
/** Application-defined close codes (daemon rejection) and the clean-close
* code — retrying either would only loop. Mirrors reconnect.ts's
* `shouldRetryClose`, minus the catalog lookup a standalone pane doesn't have. */
function defaultShouldReconnect(code: number): boolean {
return code < 4000 && code !== 1000;
}
export interface TerminalPaneProps {
sessionId: Uuid;
/** Grabs the keyboard once the socket opens. A sidebar preview passes `false`. */
focus?: boolean;
/** Asked on every close: should the socket be retried? */
shouldReconnect?: (code: number) => boolean;
/** The socket closed for good (retry declined, or the daemon rejected the
* session) — not called on an ordinary unmount. */
onClosed?: () => void;
/** Connection status changes, for a header/status badge to render. */
onStatusChange?: (status: TerminalStatus, detail?: string) => void;
className?: string;
}
export interface TerminalPaneHandle {
focusTerminal(): void;
sendInput(data: string): void;
}
export const TerminalPane = forwardRef<TerminalPaneHandle, TerminalPaneProps>(
function TerminalPane(
{ sessionId, focus = true, shouldReconnect = defaultShouldReconnect, onClosed, onStatusChange, className },
ref,
) {
const bodyRef = useRef<HTMLDivElement | null>(null);
const sendInputRef = useRef<(data: string) => void>(() => {});
const focusTerminalRef = useRef<() => void>(() => {});
const keyBar = useKeyBarModifiers();
const [keybarActive, setKeybarActive] = useState(false);
useImperativeHandle(
ref,
() => ({
focusTerminal: () => focusTerminalRef.current(),
sendInput: (data: string) => sendInputRef.current(data),
}),
[],
);
useEffect(() => {
const body = bodyRef.current;
if (!body) return;
const term = new Terminal({
cursorBlink: true,
fontFamily:
'"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: 13,
convertEol: false,
theme: {
background: "#121419",
foreground: "#e6e8ec",
cursor: "#5fe3a1",
cursorAccent: "#121419",
selectionBackground: "#2b6b4f",
},
scrollback: 5000,
allowProposedApi: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(body);
let webgl: WebglAddon | null = null;
let dprMedia: MediaQueryList | null = null;
const onDprChange = () => {
try {
term.clearTextureAtlas();
} catch {
// ignore — nothing to rebuild if the renderer already went away
}
watchDpr();
};
function watchDpr(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
try {
dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null;
dprMedia?.addEventListener("change", onDprChange);
} catch {
// ignore — a browser without `resolution` media queries just keeps
// the atlas it has
}
}
function releaseWebgl(): void {
dprMedia?.removeEventListener("change", onDprChange);
dprMedia = null;
if (!webgl) return;
const addon = webgl;
webgl = null;
try {
addon.dispose();
} catch {
// ignore — a lost context can throw on the way out
}
}
function acquireWebgl(): void {
if (webgl || disposed || !webglPolicy.enabled()) return;
try {
const addon = new WebglAddon();
addon.onContextLoss(() => {
webglPolicy.noteContextLoss();
releaseWebgl();
if (mounted) acquireWebgl();
});
term.loadAddon(addon);
webgl = addon;
watchDpr();
} catch {
webglPolicy.noteContextLoss();
webgl = null;
}
}
let mounted = false;
const tryFit = () => {
if (!mounted) return;
try {
const el2 = term.element;
const parent = el2?.parentElement;
const core = (
term as unknown as {
_core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } };
}
)._core;
const cell = core?._renderService?.dimensions?.css?.cell;
if (!el2 || !parent || !cell || !cell.width || !cell.height) {
fit.fit();
return;
}
const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null;
let availW: number;
let availH: number;
if (vp && vp.clientWidth > 0) {
availW = vp.clientWidth;
availH = vp.clientHeight;
} else {
const ps = getComputedStyle(parent);
const ts = getComputedStyle(el2);
availW = parseInt(ps.width) - (parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0);
availH = parseInt(ps.height) - (parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0);
}
const cols = Math.max(2, Math.floor(availW / cell.width));
const rows = Math.max(1, Math.floor(availH / cell.height));
if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows);
} catch {
try {
fit.fit();
} catch {
// ignore — fit fails if the container is detached or zero-sized
}
}
};
let ws: WebSocket;
let reconnectMs = INITIAL_RECONNECT_MS;
let reconnectTimer: number | null = null;
let awaitingReplay = true;
let disposed = false;
const sendControl = (msg: ClientControl) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(msg));
};
const sendInput = (data: string) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(new TextEncoder().encode(data));
};
sendInputRef.current = sendInput;
focusTerminalRef.current = () => term.focus();
const onResize = () => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
};
function connect(): void {
awaitingReplay = true;
ws = new WebSocket(attachWsUrl(sessionId));
ws.binaryType = "arraybuffer";
ws.onopen = () => {
reconnectMs = INITIAL_RECONNECT_MS;
onStatusChange?.("open");
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (mounted && focus) term.focus();
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
try {
JSON.parse(ev.data) as ClientControl;
} catch {
// ignore unknown control frames
}
return;
}
if (ev.data instanceof ArrayBuffer) {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(ev.data), () => {
if (replay) term.scrollToBottom();
});
} else if (ev.data instanceof Blob) {
void ev.data.arrayBuffer().then((b) => {
const replay = awaitingReplay;
awaitingReplay = false;
term.write(new Uint8Array(b), () => {
if (replay) term.scrollToBottom();
});
});
}
};
ws.onerror = () => {
onStatusChange?.("error");
};
ws.onclose = (ev) => {
const reason = ev.reason?.trim();
if (ev.code >= 4000 && reason && !disposed) {
onStatusChange?.("error", reason);
term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
return;
}
if (disposed) return;
if (shouldReconnect(ev.code)) {
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
onStatusChange?.("reconnecting");
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
connect();
}, delay);
return;
}
onStatusChange?.("closed", String(ev.code));
onClosed?.();
};
}
onStatusChange?.("connecting");
connect();
const dataDisp = term.onData((data) => {
sendInput(keyBar.consume(data));
});
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
});
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
const ta = term.textarea;
const onTaFocus = () => {
setKeybarActive(true);
requestAnimationFrame(tryFit);
};
const onTaBlur = () => {
setKeybarActive(false);
requestAnimationFrame(tryFit);
};
ta?.addEventListener("focus", onTaFocus);
ta?.addEventListener("blur", onTaBlur);
window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null;
visual?.addEventListener("resize", onResize);
const ro = new ResizeObserver(() => tryFit());
ro.observe(body);
document.fonts?.ready?.then(() => tryFit()).catch(() => {});
mounted = true;
acquireWebgl();
requestAnimationFrame(() => {
tryFit();
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
if (focus && ws.readyState === WebSocket.OPEN) term.focus();
});
return () => {
disposed = true;
mounted = false;
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
ro.disconnect();
window.removeEventListener("resize", onResize);
visual?.removeEventListener("resize", onResize);
ta?.removeEventListener("focus", onTaFocus);
ta?.removeEventListener("blur", onTaBlur);
dataDisp.dispose();
resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl();
try {
ws.close();
} catch {
// ignore
}
term.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- reconnecting
// a session change is a fresh connection, not a prop-driven re-render
}, [sessionId]);
return (
<div className={className ? `flex h-full flex-col ${className}` : "flex h-full flex-col"}>
<div ref={bodyRef} className="min-h-0 flex-1" />
{keybarActive && (
<KeyBar
armed={keyBar.armed}
onToggleMod={(mod) => keyBar.setMod(mod, !keyBar.armed[mod])}
onClearMods={keyBar.clearMods}
onSend={(data) => sendInputRef.current(data)}
/>
)}
</div>
);
},
);