Skip to content
379 changes: 35 additions & 344 deletions apps/staged/src/lib/features/sessions/SessionChatPane.svelte

Large diffs are not rendered by default.

43 changes: 0 additions & 43 deletions apps/staged/src/lib/features/sessions/acpTranscript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
isToolMetadataSettled,
latestAvailableCommands,
stabilizeAcpTranscriptGroups,
toolHasDetails,
type RichToolItem,
} from './acpTranscript';

Expand Down Expand Up @@ -786,48 +785,6 @@ describe('stabilizeAcpTranscriptGroups', () => {
});
});

describe('toolHasDetails', () => {
it('matches the formatted-value emptiness checks', () => {
expect(toolHasDetails(richTool({ key: 'tool:1', verb: 'Ran' }))).toBe(false);
expect(toolHasDetails(richTool({ key: 'tool:1', verb: 'Ran', rawInput: '' }))).toBe(false);
expect(toolHasDetails(richTool({ key: 'tool:1', verb: 'Ran', rawInput: { a: 1 } }))).toBe(true);
expect(toolHasDetails(richTool({ key: 'tool:1', verb: 'Ran', rawOutput: 'ok' }))).toBe(true);
expect(
toolHasDetails(
richTool({
key: 'tool:1',
verb: 'Ran',
content: [{ type: 'diff', path: '/repo/a.ts', newText: 'new' }],
})
)
).toBe(true);
expect(
toolHasDetails(
richTool({ key: 'tool:1', verb: 'Ran', content: [{ type: 'terminal', terminalId: 't-1' }] })
)
).toBe(true);
expect(
toolHasDetails(
richTool({ key: 'tool:1', verb: 'Ran', locations: [{ path: '/repo/a.ts', line: 3 }] })
)
).toBe(true);
expect(
toolHasDetails(
richTool({
key: 'tool:1',
verb: 'Ran',
result: message({ id: 9, role: 'tool_result', content: 'output' }),
})
)
).toBe(true);
// Presence-only fields that the expanded card ignores stay hidden.
expect(
toolHasDetails(richTool({ key: 'tool:1', verb: 'Ran', content: [{ type: 'diff' }] }))
).toBe(false);
expect(toolHasDetails(richTool({ key: 'tool:1', verb: 'Ran', locations: [{}] }))).toBe(false);
});
});

describe('isToolMetadataSettled', () => {
it('treats terminal statuses as settled and everything else as mutable', () => {
const row = (status?: string) =>
Expand Down
128 changes: 1 addition & 127 deletions apps/staged/src/lib/features/sessions/acpTranscript.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import type { SessionMessage } from '../../types';
import type { DisplayRootInput } from './pathDisplayRoots';
import {
formatToolDisplay,
makePathsRelative,
parseToolCall,
stripCodeFences,
verbGroupSummary,
} from './sessionModalHelpers';
import { formatToolDisplay, parseToolCall, verbGroupSummary } from './sessionModalHelpers';

export type ToolStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled';

Expand Down Expand Up @@ -308,29 +302,6 @@ export function textFromAcpContent(content: unknown): string {
return parts.join('\n').trim();
}

export interface AcpDiff {
path: string;
oldText: string | null;
newText: string;
}

export function diffsFromAcpContent(content: unknown, displayRoots?: DisplayRootInput): AcpDiff[] {
if (!Array.isArray(content)) return [];
const diffs: AcpDiff[] = [];
for (const item of content) {
if (stringProp(item, 'type') !== 'diff') continue;
const path = stringProp(item, 'path');
const newText = stringProp(item, 'newText');
if (!path || newText === null) continue;
diffs.push({
path: makePathsRelative(path, displayRoots),
oldText: stringProp(item, 'oldText'),
newText,
});
}
return diffs;
}

export function terminalRefsFromAcpContent(content: unknown): string[] {
if (!Array.isArray(content)) return [];
return content
Expand Down Expand Up @@ -368,29 +339,6 @@ export function groupRichToolsByVerb(items: RichToolItem[]): RichToolVerbGroup[]
}));
}

export function simpleUnifiedDiff(diff: AcpDiff): string {
const oldLines = (diff.oldText ?? '').split('\n');
const newLines = diff.newText.split('\n');
if (diff.oldText === null) {
return newLines.map((line) => `+${line}`).join('\n');
}
if (diff.oldText === diff.newText) return diff.newText;

const output: string[] = [];
const max = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < max; i++) {
const oldLine = oldLines[i];
const newLine = newLines[i];
if (oldLine === newLine) {
if (oldLine !== undefined) output.push(` ${oldLine}`);
} else {
if (oldLine !== undefined) output.push(`-${oldLine}`);
if (newLine !== undefined) output.push(`+${newLine}`);
}
}
return output.join('\n');
}

function groupStatusTone(items: RichToolItem[]): RichToolItem['statusTone'] {
if (items.some((item) => item.statusTone === 'danger')) return 'danger';
if (items.some((item) => item.statusTone === 'running')) return 'running';
Expand Down Expand Up @@ -870,77 +818,3 @@ function stringProp(value: unknown, key: string): string | null {
const prop = (value as Record<string, unknown>)[key];
return typeof prop === 'string' ? prop : null;
}

export function displayLocations(locations: unknown, displayRoots?: DisplayRootInput): string[] {
if (!Array.isArray(locations)) return [];
return locations
.map((location) => {
const path = stringProp(location, 'path');
if (!path) return null;
const line =
location && typeof location === 'object'
? (location as Record<string, unknown>).line
: undefined;
const suffix = typeof line === 'number' ? `:${line}` : '';
return `${makePathsRelative(path, displayRoots)}${suffix}`;
})
.filter((location): location is string => !!location);
}

export function toolResultText(item: RichToolItem): string {
const structuredText = textFromAcpContent(item.content);
if (structuredText) return structuredText;
if (typeof item.rawOutput === 'string') return item.rawOutput;
if (item.rawOutput !== undefined && item.rawOutput !== null) return formatJson(item.rawOutput);
if (item.result?.content) return stripCodeFences(item.result.content);
return '';
}

/**
* Whether an expanded tool card would have anything to show. Equivalent to
* checking the formatted values (`formatJson`, `toolResultText`,
* `diffsFromAcpContent`, …) for emptiness, but without building them — the
* formatted strings are only needed once a card is actually expanded, and
* pretty-printing large raw payloads for every collapsed card is what made
* live-transcript re-renders expensive.
*/
export function toolHasDetails(item: RichToolItem): boolean {
return (
hasJsonValue(item.rawInput) ||
hasJsonValue(item.rawOutput) ||
acpContentHasDiff(item.content) ||
acpContentHasTerminalRef(item.content) ||
hasLocation(item.locations) ||
!!toolResultText(item)
);
}

/** Mirrors `!!formatJson(value)`: only undefined/null/'' format to ''. */
function hasJsonValue(value: unknown): boolean {
return value !== undefined && value !== null && value !== '';
}

/** Mirrors `diffsFromAcpContent(content).length > 0`. */
function acpContentHasDiff(content: unknown): boolean {
if (!Array.isArray(content)) return false;
return content.some(
(item) =>
stringProp(item, 'type') === 'diff' &&
!!stringProp(item, 'path') &&
stringProp(item, 'newText') !== null
);
}

/** Mirrors `terminalRefsFromAcpContent(content).length > 0`. */
function acpContentHasTerminalRef(content: unknown): boolean {
if (!Array.isArray(content)) return false;
return content.some(
(item) => stringProp(item, 'type') === 'terminal' && !!stringProp(item, 'terminalId')
);
}

/** Mirrors `displayLocations(locations).length > 0`. */
function hasLocation(locations: unknown): boolean {
if (!Array.isArray(locations)) return false;
return locations.some((location) => !!stringProp(location, 'path'));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script lang="ts">
import type { ToolCallViewModel } from '../toolCallViewModel';
import OutputSections from './OutputSections.svelte';

interface Props {
viewModel: ToolCallViewModel;
}

let { viewModel }: Props = $props();
let commandText = $derived((viewModel.metadata.command ?? viewModel.detail) || 'Command');
// Status already shows in the header dot and the footer; only surface failure exits here.
let failureExitCode = $derived(
viewModel.output.exitCode !== null && viewModel.output.exitCode !== 0
? viewModel.output.exitCode
: null
);
</script>

<div class="tool-detail-stack">
<div class="tool-command-panel">
<div class="tool-command-line">
<span class="tool-command-prefix">$</span><span class="tool-command-text">{commandText}</span>
</div>
{#if viewModel.metadata.workingDirectory || failureExitCode !== null}
<div class="tool-field-list">
{#if viewModel.metadata.workingDirectory}
<span class="tool-field-label">Directory</span>
<span class="tool-field-value">{viewModel.metadata.workingDirectory}</span>
{/if}
{#if failureExitCode !== null}
<span class="tool-field-label">Exit</span>
<span class="tool-field-value">{failureExitCode}</span>
{/if}
</div>
{/if}
</div>

{#if viewModel.metadata.terminalRefs.length > 0}
<div class="tool-meta-row">
{#each viewModel.metadata.terminalRefs as terminalRef}
<span class="tool-chip">{terminalRef}</span>
{/each}
</div>
{/if}

<OutputSections {viewModel} copyable />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<script lang="ts">
import { summarizeToolCallLocations, type ToolCallViewModel } from '../toolCallViewModel';
import InlineToolDiff from './InlineToolDiff.svelte';
import OutputSections from './OutputSections.svelte';

interface Props {
viewModel: ToolCallViewModel;
}

let { viewModel }: Props = $props();
// The diff header already names the file; only surface path metadata it doesn't cover.
let showPath = $derived(
!!viewModel.metadata.targetPath &&
!viewModel.metadata.diffs.some((diff) => diff.path === viewModel.metadata.targetPath)
);
let locationSummary = $derived(
summarizeToolCallLocations(
viewModel.metadata.locations,
showPath ? viewModel.metadata.targetPath : null,
[viewModel.metadata.targetPath, ...viewModel.metadata.diffs.map((diff) => diff.path)]
)
);
</script>

<div class="tool-detail-stack">
{#if showPath}
<div class="tool-primary-row">
<span class="tool-field-label">Path</span>
<span class="tool-field-value"
>{viewModel.metadata.targetPath}{locationSummary.pathSuffix}</span
>
</div>
{/if}

{#if locationSummary.chips.length > 0}
<div class="tool-meta-row">
{#each locationSummary.chips as chip}
<span class="tool-chip">{chip}</span>
{/each}
</div>
{/if}

{#if viewModel.metadata.diffs.length > 0}
{#each viewModel.metadata.diffs as diff}
<InlineToolDiff {diff} />
{/each}
{:else if viewModel.metadata.inputText}
<section>
<div class="tool-panel-label">Input</div>
<pre class="tool-code-output">{viewModel.metadata.inputText}</pre>
</section>
{/if}

<OutputSections {viewModel} />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script lang="ts">
import type { ToolCallViewModel } from '../toolCallViewModel';
import InlineToolDiff from './InlineToolDiff.svelte';
import OutputSections from './OutputSections.svelte';

interface Props {
viewModel: ToolCallViewModel;
}

let { viewModel }: Props = $props();
</script>

<div class="tool-detail-stack">
{#if viewModel.metadata.locations.length > 0}
<div class="tool-meta-row">
{#each viewModel.metadata.locations as location}
<span class="tool-chip">{location.display}</span>
{/each}
</div>
{/if}

{#if viewModel.metadata.inputText}
<section>
<div class="tool-panel-label">Input</div>
<pre class="tool-code-output">{viewModel.metadata.inputText}</pre>
</section>
{/if}

{#each viewModel.metadata.diffs as diff}
<InlineToolDiff {diff} />
{/each}

{#if viewModel.metadata.terminalRefs.length > 0}
<section>
<div class="tool-panel-label">Terminal</div>
<div class="tool-meta-row">
{#each viewModel.metadata.terminalRefs as terminalRef}
<span class="tool-chip">{terminalRef}</span>
{/each}
</div>
</section>
{/if}

<OutputSections {viewModel} />
</div>
Loading