// 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 = { 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; 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:`. */ export function activityGroupsByTurn( rows: TranscriptRow[], pendingApprovalId: string | null, turnActive: boolean, ): Map { // First: count tools per turn to know which turns qualify for grouping. const toolCounts = new Map(); 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(); 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(); 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(); 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; 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(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); }); }