Stream forming tool args into the pending tool row

While the model writes tool arguments the pending row showed only a
generic placeholder plus 'Receiving tool input.' for what can be tens
of seconds on slow routes. OpenCode streams the forming call into the
mutable tool part; Pulse now shows the live tail of the raw args in
the pending row whenever they are still unparsable, and drops it the
moment the input parses into a real summary or command preview. The
raw input was already carried by tool_progress events; this only
renders it.
This commit is contained in:
rcourtman
2026-06-11 16:57:46 +01:00
parent e1cd15d762
commit 07e4501d08
2 changed files with 68 additions and 0 deletions
@@ -608,6 +608,19 @@ export const PendingToolBlock: Component<PendingToolBlockProps> = (props) => {
if (!preview || summary.startsWith('$ ') || preview === summary) return '';
return preview;
});
// While the model is still streaming the tool arguments the raw input is
// partial JSON that no parser can summarize yet. Show its live tail (the
// OpenCode mutable tool-part pattern: you watch the call being written)
// instead of leaving only the generic placeholder.
const formingArgsPreview = createMemo(() => {
if (commandPreview()) return '';
if (!isPlaceholderToolInputSummary(parsedInputSummary())) return '';
const raw = (props.tool.rawInput || '').replace(/\s+/g, ' ').trim();
if (!raw) return '';
const maxChars = 120;
if (raw.length <= maxChars) return raw;
return '…' + raw.slice(raw.length - maxChars);
});
const status = createMemo(() => props.tool.status || 'pending');
const [now, setNow] = createSignal(Date.now());
const statusLabel = createMemo(() => {
@@ -696,6 +709,14 @@ export const PendingToolBlock: Component<PendingToolBlockProps> = (props) => {
<Show when={commandPreview()}>
<ToolCommandPreview preview={commandPreview()} />
</Show>
<Show when={formingArgsPreview()}>
<div
class="mt-1 min-w-0 font-mono text-[10px] leading-snug text-muted"
aria-label="Tool input streaming"
>
<span class="block truncate">{formingArgsPreview()}</span>
</div>
</Show>
</div>
</div>
@@ -818,6 +818,53 @@ describe('PendingToolBlock', () => {
expect(screen.getByText('df -h')).toBeInTheDocument();
});
it('streams forming raw args into the pending row while they are unparsable', () => {
// Mid-stream tool args are partial JSON: no summary or command preview
// can parse yet, so the row must show the live raw tail instead of only
// the generic placeholder.
render(() => (
<PendingToolBlock
tool={makePending({
name: 'pulse_query',
input: '',
rawInput: '{"action":"topology","summary_on',
status: 'pending',
})}
/>
));
expect(screen.getByLabelText('Tool input streaming')).toHaveTextContent(
'{"action":"topology","summary_on',
);
});
it('shows the tail of long forming raw args', () => {
const head = '{"action":"exec","command":"';
const tail = 'x'.repeat(130);
render(() => (
<PendingToolBlock
tool={makePending({ name: 'pulse_query', input: '', rawInput: head + tail, status: 'pending' })}
/>
));
const preview = screen.getByLabelText('Tool input streaming');
expect(preview.textContent).toMatch(/^…/);
expect(preview.textContent).toContain('xxx');
expect(preview.textContent!.length).toBeLessThanOrEqual(121);
});
it('does not show the forming-args preview once the input parses to a summary', () => {
render(() => (
<PendingToolBlock
tool={makePending({
name: 'pulse_query',
input: '{"action":"topology"}',
rawInput: '{"action":"topology"}',
status: 'running',
})}
/>
));
expect(screen.queryByLabelText('Tool input streaming')).not.toBeInTheDocument();
});
it('truncates input summaries longer than 28 chars', () => {
const longInput = 'B'.repeat(55);
render(() => <PendingToolBlock tool={makePending({ input: longInput })} />);