diff --git a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte
index e494067f..f1cc61e5 100644
--- a/apps/staged/src/lib/features/sessions/SessionChatPane.svelte
+++ b/apps/staged/src/lib/features/sessions/SessionChatPane.svelte
@@ -35,18 +35,12 @@
import CircleStop from '@lucide/svelte/icons/circle-stop';
import Copy from '@lucide/svelte/icons/copy';
import Check from '@lucide/svelte/icons/check';
- import Clock from '@lucide/svelte/icons/clock';
- import CircleAlert from '@lucide/svelte/icons/circle-alert';
- import CircleCheck from '@lucide/svelte/icons/circle-check';
- import CircleDot from '@lucide/svelte/icons/circle-dot';
- import CircleSlash from '@lucide/svelte/icons/circle-slash';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Zap from '@lucide/svelte/icons/zap';
import GitBranch from '@lucide/svelte/icons/git-branch';
import BookOpen from '@lucide/svelte/icons/book-open';
import FileText from '@lucide/svelte/icons/file-text';
- import ExternalLink from '@lucide/svelte/icons/external-link';
import ImagePlus from '@lucide/svelte/icons/image-plus';
import Spinner from '../../shared/Spinner.svelte';
import { isResumableReason } from '../../types';
@@ -108,22 +102,15 @@
import { hasXmlBlocks, sessionEndMessage, XML_BLOCK_TAGS } from './sessionModalHelpers';
import {
buildAcpTranscriptGroups,
- diffsFromAcpContent,
- displayLocations,
formatJson,
groupRichToolsByVerb,
isToolMetadataSettled,
latestAvailableCommands,
- simpleUnifiedDiff,
stabilizeAcpTranscriptGroups,
- terminalRefsFromAcpContent,
- toolHasDetails,
- toolResultText,
transcriptGroupKey,
type AcpCommand,
type AcpTranscriptEvent,
type AcpTranscriptGroup,
- type RichToolItem,
} from './acpTranscript';
import {
displayRootKey,
@@ -132,6 +119,8 @@
type DisplayRootInput,
} from './pathDisplayRoots';
import PipelineSteps from './PipelineSteps.svelte';
+ import ToolCallCard from './tool-calls/ToolCallCard.svelte';
+ import ToolCallHeader from './tool-calls/ToolCallHeader.svelte';
import { highlightMatches, clearHighlights, scrollToMatch } from '../../shared/textHighlight';
import '../../shared/markdown/diagramStyles.css';
import {
@@ -1739,131 +1728,11 @@
{rawInputText}
- {/if}
- {#each diffs as diff}
- {simpleUnifiedDiff(diff)}
- {/each}
- {#if terminalRefs.length > 0}
- {resultText}
- {/if}
- {#if rawOutputText && rawOutputText !== resultText}
- {rawOutputText}
- {/if}
- {viewModel.metadata.inputText}
+ {viewModel.metadata.inputText}
+ {#each segments(row.text, row.highlights) as segment}{#if segment.highlighted}{segment.text}{:else}{segment.text}{/if}{/each}
+ {network.requestHeaders}
+ {network.requestBody}
+ {network.responseHeaders}
+ {network.responseBody}
+ {block.text}
+ {match.snippet}
+ would show literally.
+ const contentText = stripCodeFences(textFromAcpContent(item.content));
+ const errorText = extractErrorText(rawRecord, item.status);
+ const stdout = valueText(firstValue(rawRecord, ['stdout', 'standardOutput', 'standard_output']));
+ const stderr = valueText(firstValue(rawRecord, ['stderr', 'standardError', 'standard_error']));
+ const exitCode = firstNumber(rawRecord, ['exitCode', 'exit_code', 'code']);
+ const structuredOutput = valueText(
+ firstValue(rawRecord, ['output', 'text', 'body', 'response', 'content', 'result'])
+ );
+ const resultText = item.result?.content ? stripCodeFences(item.result.content) : '';
+ const primaryText =
+ contentText ||
+ (typeof item.rawOutput === 'string' ? item.rawOutput : '') ||
+ structuredOutput ||
+ stdout ||
+ stderr ||
+ resultText;
+ const hasAnyOutput = !!(primaryText || hasRawText || errorText || stdout || stderr);
+ const isWaiting = item.status === 'pending' || item.status === 'in_progress';
+
+ return {
+ state: hasAnyOutput ? 'output' : isWaiting ? 'waiting' : 'empty',
+ primaryText,
+ get rawText() {
+ return (rawTextCache ??= formatJson(rawOutput));
+ },
+ hasRawText,
+ errorText,
+ stdout,
+ stderr,
+ exitCode,
+ emptyLabel: hasAnyOutput ? null : isWaiting ? 'Waiting for output' : 'No output',
+ };
+}
+
+export function buildToolCallSections(
+ item: Pick,
+ metadata: ToolCallMetadata,
+ output: ToolCallOutput
+): ToolCallSection[] {
+ const sections: ToolCallSection[] = [];
+
+ if (metadata.locations.length > 0) {
+ sections.push({ kind: 'locations', locations: metadata.locations });
+ }
+
+ if (metadata.hasInputText) {
+ // `text` defers to the metadata's memoized getter so the caret-driven
+ // `hasDetails` check (which only reads `kind`) never triggers formatting.
+ sections.push({
+ kind: 'input',
+ label: 'Input',
+ get text() {
+ return metadata.inputText;
+ },
+ });
+ }
+
+ for (const diff of metadata.diffs) {
+ sections.push({ kind: 'diff', diff });
+ }
+
+ if (metadata.terminalRefs.length > 0) {
+ sections.push({ kind: 'terminal_refs', label: 'Terminal', refs: metadata.terminalRefs });
+ }
+
+ let hasStructuredOutput = false;
+ const outputTone = item.status === 'cancelled' ? 'cancelled' : 'normal';
+ if (output.errorText) {
+ sections.push({
+ kind: 'output',
+ source: 'error',
+ label: 'Error',
+ text: output.errorText,
+ tone: 'danger',
+ });
+ hasStructuredOutput = true;
+ }
+
+ if (output.stdout) {
+ sections.push({
+ kind: 'output',
+ source: 'stdout',
+ label: 'Stdout',
+ text: output.stdout,
+ tone: outputTone,
+ });
+ hasStructuredOutput = true;
+ }
+
+ if (output.stderr && output.stderr !== output.errorText) {
+ sections.push({
+ kind: 'output',
+ source: 'stderr',
+ label: 'Stderr',
+ text: output.stderr,
+ tone: item.status === 'failed' ? 'danger' : outputTone,
+ });
+ hasStructuredOutput = true;
+ }
+
+ if (
+ output.primaryText &&
+ output.primaryText !== output.errorText &&
+ output.primaryText !== output.stdout &&
+ output.primaryText !== output.stderr
+ ) {
+ sections.push({
+ kind: 'output',
+ source: 'primary',
+ label: 'Output',
+ text: output.primaryText,
+ tone: outputTone,
+ });
+ hasStructuredOutput = true;
+ }
+
+ // Raw JSON is a fallback for output we could not render structurally,
+ // never a companion to structured output sections.
+ if (output.hasRawText && !hasStructuredOutput) {
+ sections.push({
+ kind: 'raw_output',
+ label: 'Raw output',
+ get text() {
+ return output.rawText;
+ },
+ });
+ }
+
+ if (output.emptyLabel) {
+ sections.push({ kind: 'empty', label: output.emptyLabel });
+ }
+
+ sections.push({
+ kind: 'status',
+ label: item.statusLabel,
+ status: item.status,
+ tone: item.statusTone,
+ });
+
+ return sections;
+}
+
+export function extractToolCallDiffs(
+ content: unknown,
+ displayRoots?: DisplayRootInput
+): ToolCallDiff[] {
+ if (!Array.isArray(content)) return [];
+
+ const diffs: ToolCallDiff[] = [];
+ for (const block of content) {
+ if (stringProp(block, 'type') !== 'diff') continue;
+ const path = stringProp(block, 'path');
+ if (!path) continue;
+
+ const oldText = nullableStringProp(block, 'oldText');
+ const newText = nullableStringProp(block, 'newText');
+ if (oldText === null && newText === null) continue;
+
+ diffs.push({
+ path: makePathsRelative(path, displayRoots),
+ oldText,
+ newText,
+ kind: diffKind(oldText, newText),
+ });
+ }
+
+ return diffs;
+}
+
+export function extractToolCallLocations(
+ locations: unknown,
+ displayRoots?: DisplayRootInput
+): ToolCallLocation[] {
+ if (!Array.isArray(locations)) return [];
+
+ return locations
+ .map((location) => {
+ const path = stringProp(location, 'path');
+ if (!path) return null;
+ const displayPath = makePathsRelative(path, displayRoots);
+ const line = numberProp(location, 'line');
+ const column = numberProp(location, 'column');
+ const suffix = `${line !== null ? `:${line}` : ''}${column !== null ? `:${column}` : ''}`;
+ return {
+ path: displayPath,
+ display: `${displayPath}${suffix}`,
+ line,
+ column,
+ };
+ })
+ .filter((location): location is ToolCallLocation => location !== null);
+}
+
+export interface ToolCallLocationSummary {
+ /** ":line[:column]" from the first location matching the Path field's path. */
+ pathSuffix: string;
+ /** Labels for the remaining locations the card doesn't already name. */
+ chips: string[];
+}
+
+/**
+ * Fold locations into a card's Path field and chips without repeating paths
+ * the card already names. The first location matching `pathFieldPath` becomes
+ * a ":line" suffix on the Path field; the rest become chips — full
+ * "path:line" displays for new paths, bare "Line N" labels for covered paths,
+ * and dropped entirely when they add no line info.
+ */
+export function summarizeToolCallLocations(
+ locations: ToolCallLocation[],
+ pathFieldPath: string | null,
+ coveredPaths: Array = []
+): ToolCallLocationSummary {
+ const covered = new Set();
+ for (const path of [pathFieldPath, ...coveredPaths]) {
+ if (path) covered.add(path);
+ }
+
+ const suffixIndex = pathFieldPath
+ ? locations.findIndex((location) => location.path === pathFieldPath && location.line !== null)
+ : -1;
+
+ const chips = locations.flatMap((location, index) => {
+ if (index === suffixIndex) return [];
+ if (!covered.has(location.path)) return [location.display];
+ if (location.line === null) return [];
+ return [`Line ${location.line}${location.column !== null ? `:${location.column}` : ''}`];
+ });
+
+ return {
+ pathSuffix: suffixIndex >= 0 ? locationLineSuffix(locations[suffixIndex]) : '',
+ chips,
+ };
+}
+
+function locationLineSuffix(location: ToolCallLocation): string {
+ if (location.line === null) return '';
+ return `:${location.line}${location.column !== null ? `:${location.column}` : ''}`;
+}
+
+export function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+export function stringProp(value: unknown, key: string): string | null {
+ if (!isRecord(value)) return null;
+ const prop = value[key];
+ return typeof prop === 'string' ? prop : null;
+}
+
+function nullableStringProp(value: unknown, key: string): string | null {
+ if (!isRecord(value)) return null;
+ const prop = value[key];
+ return typeof prop === 'string' ? prop : null;
+}
+
+function numberProp(value: unknown, key: string): number | null {
+ if (!isRecord(value)) return null;
+ const prop = value[key];
+ return typeof prop === 'number' && Number.isFinite(prop) ? prop : null;
+}
+
+function recordOrNull(value: unknown): Record | null {
+ return isRecord(value) ? value : null;
+}
+
+function normalizeToken(value: string | null | undefined): string | null {
+ if (!value) return null;
+ return value.trim().toLowerCase();
+}
+
+function compactToken(value: string | null | undefined): string | null {
+ const normalized = normalizeToken(value);
+ return normalized ? normalized.replace(/[^a-z0-9]/g, '') : null;
+}
+
+function categoryFromToken(value: string | null | undefined): ToolCallCategory | null {
+ const normalized = normalizeToken(value);
+ const compact = compactToken(value);
+ if (!normalized || !compact) return null;
+
+ if (EDIT_KINDS.has(normalized) || EDIT_KINDS.has(compact)) return 'edit';
+ if (COMMAND_KINDS.has(normalized) || COMMAND_KINDS.has(compact)) return 'command';
+ if (READ_KINDS.has(normalized) || READ_KINDS.has(compact)) return 'read';
+ if (SEARCH_KINDS.has(normalized) || SEARCH_KINDS.has(compact)) return 'search';
+ if (NETWORK_KINDS.has(normalized) || NETWORK_KINDS.has(compact)) return 'network';
+
+ return null;
+}
+
+function categoryFromTitle(title: string | null): ToolCallCategory | null {
+ if (!title) return null;
+ const firstWord = title.trim().split(/\s+/, 1)[0];
+ return categoryFromToken(title) ?? categoryFromToken(firstWord);
+}
+
+function categoryFromParsedCommands(commands: ParsedToolCommand[]): ToolCallCategory | null {
+ for (const command of commands) {
+ const typeCategory = categoryFromToken(command.type);
+ if (typeCategory) return typeCategory;
+ if (command.cmd) return 'command';
+ }
+ return null;
+}
+
+function categoryFromVerb(verb: string): ToolCallCategory | null {
+ if (EDIT_VERBS.has(verb)) return 'edit';
+ if (COMMAND_VERBS.has(verb)) return 'command';
+ if (READ_VERBS.has(verb)) return 'read';
+ if (SEARCH_VERBS.has(verb)) return 'search';
+ return null;
+}
+
+function parsedCommandsFrom(input: Record | null): ParsedToolCommand[] {
+ const parsedCmd = input?.parsed_cmd ?? input?.parsedCmd;
+ if (!Array.isArray(parsedCmd)) return [];
+
+ return parsedCmd.filter(isRecord).map((command) => ({
+ type: stringProp(command, 'type'),
+ cmd: stringProp(command, 'cmd') ?? stringProp(command, 'command'),
+ path: stringProp(command, 'path'),
+ query: stringProp(command, 'query'),
+ name: stringProp(command, 'name'),
+ }));
+}
+
+function uniqueParsedCommand(
+ command: ParsedToolCommand,
+ index: number,
+ commands: ParsedToolCommand[]
+): boolean {
+ return (
+ commands.findIndex(
+ (candidate) =>
+ candidate.type === command.type &&
+ candidate.cmd === command.cmd &&
+ candidate.path === command.path &&
+ candidate.query === command.query &&
+ candidate.name === command.name
+ ) === index
+ );
+}
+
+function firstString(input: Record | null, keys: string[]): string | null {
+ if (!input) return null;
+ for (const key of keys) {
+ const value = input[key];
+ if (typeof value === 'string' && value.trim()) return value;
+ }
+ return null;
+}
+
+function firstParsedCommandField(
+ commands: ParsedToolCommand[],
+ field: keyof ParsedToolCommand
+): string | null {
+ for (const command of commands) {
+ const value = command[field];
+ if (typeof value === 'string' && value.trim()) return value;
+ }
+ return null;
+}
+
+function firstCommandValue(commands: ParsedToolCommand[]): string | null {
+ return firstParsedCommandField(commands, 'cmd');
+}
+
+function firstValue(input: Record | null, keys: string[]): unknown {
+ if (!input) return undefined;
+ for (const key of keys) {
+ if (input[key] !== undefined && input[key] !== null) return input[key];
+ }
+ return undefined;
+}
+
+function firstNumber(input: Record | null, keys: string[]): number | null {
+ if (!input) return null;
+ for (const key of keys) {
+ const value = input[key];
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
+ }
+ return null;
+}
+
+function valueText(value: unknown): string {
+ if (value === undefined || value === null) return '';
+ if (typeof value === 'string') return value;
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
+ return formatJson(value);
+}
+
+/** Mirrors when {@link formatJson} yields a non-empty string, without formatting. */
+function hasFormattableJson(value: unknown): boolean {
+ if (value === undefined || value === null) return false;
+ if (typeof value === 'string') return value.length > 0;
+ return true;
+}
+
+function extractErrorText(rawOutput: Record | null, status: ToolStatus): string {
+ const error = firstValue(rawOutput, ['error', 'errorMessage', 'error_message']);
+ if (error !== undefined && error !== null) return valueText(error);
+
+ if (status !== 'failed') return '';
+ const stderr = valueText(firstValue(rawOutput, ['stderr', 'standardError', 'standard_error']));
+ return stderr;
+}
+
+function normalizeHttpMethod(method: string | null): string | null {
+ return method ? method.toUpperCase() : null;
+}
+
+function relativeValue(value: string | null, displayRoots?: DisplayRootInput): string | null {
+ return value ? makePathsRelative(value, displayRoots) : null;
+}
+
+function hasNetworkMetadata(value: unknown): boolean {
+ if (!isRecord(value)) return false;
+ return Object.keys(value).some((key) => NETWORK_KEYS.has(key));
+}
+
+function diffKind(oldText: string | null, newText: string | null): ToolCallDiffKind {
+ if (oldText === null) return 'created';
+ if (newText === null) return 'deleted';
+ return oldText === newText ? 'unchanged' : 'modified';
+}