The collapsed tool group now reads "N tool calls · Label 'arg'" so the operator sees the current command at a glance. While a turn is active, the most recent agent text stays outside the group as visible narration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
390 lines
15 KiB
TypeScript
390 lines
15 KiB
TypeScript
// 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 the old structuredRender.ts that were data, not
|
||
// markup.
|
||
|
||
import type {
|
||
AcpContentBlock,
|
||
AcpPermissionOption,
|
||
AcpToolCallContent,
|
||
ReducedState,
|
||
StructuredApprovalDecision,
|
||
TranscriptRow,
|
||
TranscriptRowKind,
|
||
TurnMeta,
|
||
} from "../../types.js";
|
||
import { computeDiffLines, diffStats } from "./diff.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[];
|
||
/** `splitToolTitle` of the last tool row — the collapsed header shows it. */
|
||
lastToolLabel: string;
|
||
lastToolArg: string;
|
||
}
|
||
|
||
/**
|
||
* 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).
|
||
*
|
||
* While a turn is still live, the most recent text or thought row trailing the
|
||
* group also stays outside as visible narration — the operator always sees the
|
||
* agent's latest reasoning without opening the group.
|
||
*
|
||
* 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: [], lastToolLabel: "", lastToolArg: "" };
|
||
// Flush any pending rows — a tool follows, so they're intermediate.
|
||
group.rows.push(...pending);
|
||
pending = [];
|
||
group.rows.push(row);
|
||
const tool = (row.data ?? {}) as { title?: string; name?: string | null; kind?: string };
|
||
const { label, arg } = splitToolTitle(tool.title, tool.name ?? undefined, tool.kind);
|
||
group.lastToolLabel = label;
|
||
group.lastToolArg = arg;
|
||
groups.set(currentGid, group);
|
||
}
|
||
}
|
||
// Trailing text/thought rows after the last tool in this turn.
|
||
// While the turn is still live, the newest one stays outside as visible
|
||
// narration — the operator always sees the agent's latest reasoning.
|
||
// A final agent_text (the "answer") stays outside regardless.
|
||
const lastGroup = groups.get(currentGid);
|
||
if (lastGroup && pending.length > 0) {
|
||
const allThoughts = pending.every((row) => row.kind === "agent_thought");
|
||
if (allThoughts) {
|
||
// Pure thoughts: absorb all when done, keep the newest outside while live.
|
||
const absorb = turnActive ? pending.slice(0, -1) : pending;
|
||
lastGroup.rows.push(...absorb);
|
||
} else if (turnActive) {
|
||
// Mixed or agent_text trailing: keep the very last row outside while live,
|
||
// absorb the rest.
|
||
lastGroup.rows.push(...pending.slice(0, -1));
|
||
}
|
||
// When the turn ends with agent_text last, nothing extra is absorbed — the
|
||
// text stays outside as the final answer.
|
||
}
|
||
|
||
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);
|
||
});
|
||
}
|