This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/components/structured/model.ts
T
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

370 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});
}