Commit 84746c5c9 for llama.cpp
commit 84746c5c94c229af4bd90070faba495cb07c3278
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date: Thu Oct 22 07:14:47 2026 +0200
ui: Add "Show tool call in progress" setting (#30681)
* feat(ui): add Show tool call in progress display setting
Add the showToolCallInProgress key and its checkbox to Settings > Display,
right next to its "always show" counterpart. Upstream dropped a setting of
this exact name in 43b5e63589, folding it into alwaysShowToolCallContent
because the old name promised expansion that outlives the call; this brings
back the narrower, in-progress-only meaning.
The entry is registry-driven, so SETTING_CONFIG_DEFAULT, the dialog field and
the ui_settings admin baseline pick it up without extra wiring. It ships
enabled, mirroring Show thought in progress, and the always-show help text is
reworded so the two toggles read as a pair rather than contradicting each
other.
Assisted by: llama-ui (ggml-org/Qwen3.8-Flash-Next:Q8_0)
* feat(ui): expand tool call blocks while they are in progress
Drive the default expansion of TOOL_CALL_STREAMING and TOOL_CALL_PENDING from
showToolCallInProgress and leave TOOL_CALL, the terminal state, to
alwaysShowToolCallContent. A finished call now folds back on its own once its
result lands, which is exactly how the reasoning block behaves when the
thought completes.
Manual toggling keeps precedence over both defaults in either direction:
isExpanded() only consults the settings while expandedStates holds no entry
for the section.
Client tests pin the truth table for the two toggles, including the automatic
fold-back and both manual overrides.
Assisted by: llama-ui (ggml-org/Qwen3.8-Flash-Next:Q8_0)
* fix(ui): keep running tool calls expanded
A call the agentic loop reports as running now outranks its section type.
exec_shell_command persists an empty result before reading any output, so a
live call is already TOOL_CALL and folded back, which hid the streaming
output the setting is meant to show.
Move the expansion default to isSectionExpandedByDefault so the running-id
rule is covered by a truth table, and test the pending -> finished transition
instead of only the finished state: a block that only ever got mounted
finished cannot prove it folds back on its own.
Assisted by: llama-ui (ggml-org/Qwen3.8-Flash-Next:Q8_0)
* feat(ui): stream the exec command from the first character
The exec parser read its args strictly, so while the model was still
typing the call the block title stayed empty and the body claimed
"Running...". Parse the args partially (the read_file / edit_file /
write_file pattern), keep a terminal cursor while the args JSON is
incomplete, and stand the conversation cwd in for the x-tool-cwd that
only arrives with the result message, so the prompt is there from the
first character. The body stays empty while the args stream, shows
"Executing command..." once the call is executing, and the wrapper
drops its padding for empty phases so the title sits flush with the
frame.
Assisted by: Delta (zai-org/GLM-5.3)
* fix(ui): stream file tool bodies even before a path arrives
write_file and edit_file refused to render until their args carried a
path, so a call that streams its body first (or a same-named tool with
other arg names) showed an empty block. Meta now carries optional
fileName/filePath, path alias reading moves to shared helpers, and the
blocks fall back to the raw args JSON when no known content key is
present.
Assisted by: Delta (zai-org/GLM-5.3)
* fix(ui): keep an empty write_file body instead of the raw args
A call carrying {"content":""} fell through to the raw-args fallback, so
a finished write of an empty file showed the args JSON. The parser now
leaves content undefined when the key is missing and the block tests
!== undefined, so an empty write renders as an empty code block with its
byte count.
Assisted by: DeepSeek-V4.1-Flash (deepseek-ai)
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte
index 6b0b57328..57e49f9ee 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte
@@ -23,13 +23,15 @@
interface Props {
section: AgenticSection;
attachments?: DatabaseMessageExtra[];
+ conversationCwd?: string;
open: boolean;
isStreaming: boolean;
isExecuting?: boolean;
onToggle?: () => void;
}
- let { attachments, isExecuting, isStreaming, onToggle, open, section }: Props = $props();
+ let { attachments, conversationCwd, isExecuting, isStreaming, onToggle, open, section }: Props =
+ $props();
// Runs for every tool block on mount, before the body renders: the cheap
// content prefilter and the tool-name allow-list come first so blobs from
@@ -60,6 +62,7 @@
{:else if section.toolName === BuiltInTool.SERVER_EXEC_SHELL_COMMAND}
<ChatMessageToolCallBlockExecShellCommand
{attachments}
+ {conversationCwd}
{isExecuting}
{isStreaming}
{onToggle}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte
index e187ec640..f781cce49 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte
@@ -2,10 +2,12 @@
import { parseEditFileMeta, parseEditFileTitleMeta } from './parsers/edit-file';
import ToolCallBlock from './ToolCallBlock.svelte';
import { XCircle } from '@lucide/svelte';
+ import { SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
+ import { FileTypeText } from '$lib/enums';
import { toolsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
- import { abbreviateHome, computeLineDiff, prefixFor } from '$lib/utils';
+ import { abbreviateHome, computeLineDiff, formatJsonPretty, prefixFor } from '$lib/utils';
interface Props {
section: AgenticSection;
@@ -44,16 +46,16 @@
</span>
{/snippet}
- {#snippet children(meta, _ctx)}
- {#if meta?.errorMessage}
+ {#snippet children(_meta, ctx)}
+ {#if editFileBody?.errorMessage}
<div
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
>
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
- <span>{meta.errorMessage}</span>
+ <span>{editFileBody.errorMessage}</span>
</div>
- {:else if meta && editFileBody && editFileBody.edits.length > 0}
+ {:else if editFileBody?.edits.length}
{#each editDiffs as diffLines, ei (ei)}
<div class={ei === 0 ? '' : 'mt-3'}>
<div class="mb-1.5 text-xs text-muted-foreground/70 italic">
@@ -79,14 +81,28 @@
{/each}
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
- {#if meta.resultMessage}
- {meta.resultMessage}{meta.editsApplied != null ? RESULT_STAT_SEPARATOR : ''}{/if}
-
- {#if meta.editsApplied != null}
- <span class="font-mono">{meta.editsApplied}</span>
- {meta.editsApplied === 1 ? 'edit' : 'edits'} applied
+ {#if editFileBody.resultMessage}
+ {editFileBody.resultMessage}{editFileBody.editsApplied != null
+ ? RESULT_STAT_SEPARATOR
+ : ''}{/if}
+
+ {#if editFileBody.editsApplied != null}
+ <span class="font-mono">{editFileBody.editsApplied}</span>
+ {editFileBody.editsApplied === 1 ? 'edit' : 'edits'} applied
{/if}
</div>
+ {:else if section.toolArgs}
+ <!-- no usable edits: not streamed yet, or a same-named tool with other arg names -->
+ <SyntaxHighlightedCode
+ code={formatJsonPretty(section.toolArgs)}
+ language={FileTypeText.JSON}
+ maxHeight={MAX_HEIGHT_CODE_BLOCK}
+ streaming={ctx.isCodeStreaming}
+ />
+ {:else if ctx.isStreaming}
+ <div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
+ Receiving arguments...
+ </div>
{:else}
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">No edits</div>
{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte
index 075cde679..de7205443 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte
@@ -11,7 +11,7 @@
import { AlertTriangle, Check, Loader2, XCircle } from '@lucide/svelte';
import { CollapsibleTerminalBlock } from '$lib/components/app';
import { SETTINGS_KEYS, TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants';
- import { AttachmentType } from '$lib/enums';
+ import { AgenticSectionType, AttachmentType } from '$lib/enums';
import { settingsStore, toolsStore } from '$lib/stores';
import type { AgenticSection, DatabaseMessageExtra, ToolResultLine } from '$lib/types';
import {
@@ -32,17 +32,40 @@
* tool call. Drives max-height + auto-scroll while true; releases
* them when the loop reports this call as done. */
isExecuting?: boolean;
+ /** Working directory of the conversation this call belongs to; stands
+ * in for `section.toolCwd` until the result message persists the
+ * real one. */
+ conversationCwd?: string;
attachments?: DatabaseMessageExtra[];
onToggle?: () => void;
}
- let { attachments, isExecuting = false, isStreaming, onToggle, open, section }: Props = $props();
+ let {
+ attachments,
+ conversationCwd,
+ isExecuting = false,
+ isStreaming,
+ onToggle,
+ open,
+ section
+ }: Props = $props();
// `isLive` covers all in-flight phases: pre-chunk spinner and
// streaming itself. Frozen output (tool done while agent continues)
// is not live.
const isLive = $derived(isExecuting);
+ const isPreResult = $derived(
+ section.type === AgenticSectionType.TOOL_CALL_PENDING ||
+ section.type === AgenticSectionType.TOOL_CALL_STREAMING
+ );
+
+ // The model is still typing the call while the args JSON doesn't parse;
+ // the title keeps a terminal cursor until it does.
+ const commandStreaming = $derived(
+ isStreaming && isPreResult && !isParsableJson(section.toolArgs)
+ );
+
const execShellMeta = $derived(parseExecShellCommandMeta(section));
const execShellError = $derived(parseExecShellCommandError(section.toolResult));
const execShellExitStatus: ExecShellExitStatus | undefined = $derived(
@@ -76,9 +99,11 @@
// The working directory the command ran with, persisted per call on the
// tool result message (it travels via the x-tool-cwd header, not the tool
- // args). Reading it from the section keeps it accurate even if the
- // conversation cwd changes later.
- const cwd = $derived(section.toolCwd);
+ // args). Until that message exists, the conversation cwd is the best known
+ // value - it is what the agentic loop passes when executing the call.
+ const cwd = $derived(
+ section.toolCwd ?? (isStreaming && isPreResult ? conversationCwd : undefined)
+ );
const home = $derived(toolsStore.serverHome);
const wdDisplay = $derived(abbreviateHome(cwd ?? '', home));
@@ -112,6 +137,18 @@
);
}
+ function isParsableJson(value: string | undefined): boolean {
+ if (!value) return false;
+
+ try {
+ JSON.parse(value);
+
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
function scrollToBottomOnFrame() {
if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
@@ -185,6 +222,10 @@
{:else}
<span class="font-mono">{execShellMeta?.command}</span>
{/if}
+
+ {#if commandStreaming}
+ <span class="exec-cursor"></span>
+ {/if}
{/snippet}
<ToolCallBlock
@@ -202,10 +243,10 @@
{/snippet}
{#snippet children(_meta, ctx)}
- {#if ctx.isPending}
+ {#if (ctx.isPending && !commandStreaming) || (isLive && !section.toolResult)}
<div class="flex items-start gap-2 text-xs text-muted-foreground/70">
<Loader2 class="h-3 w-3 animate-spin" />
- Running...
+ Executing command...
</div>
{:else if execShellError}
<div class="flex items-start gap-2 text-xs text-red-600 italic dark:text-red-400">
@@ -277,6 +318,22 @@
margin-right: var(--exec-wd-margin);
}
+ .exec-cursor {
+ display: inline-block;
+ width: 0.5em;
+ height: 1.05em;
+ margin-left: 0.15rem;
+ vertical-align: text-bottom;
+ background: var(--foreground);
+ animation: exec-cursor-blink 1.1s steps(1) infinite;
+ }
+
+ @keyframes exec-cursor-blink {
+ 50% {
+ opacity: 0;
+ }
+ }
+
.terminal-output {
overscroll-behavior: contain;
}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte
index ac2a8e657..f3f676017 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte
@@ -4,9 +4,10 @@
import { XCircle } from '@lucide/svelte';
import { SyntaxHighlightedCode } from '$lib/components/app';
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
+ import { FileTypeText } from '$lib/enums';
import { toolsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
- import { abbreviateHome } from '$lib/utils';
+ import { abbreviateHome, formatJsonPretty } from '$lib/utils';
interface Props {
section: AgenticSection;
@@ -42,32 +43,48 @@
</span>
{/snippet}
- {#snippet children(meta, ctx)}
- {#if meta?.errorMessage}
+ {#snippet children(_meta, ctx)}
+ {#if writeFileBody?.errorMessage}
<div
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
>
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
- <span>{meta.errorMessage}</span>
+ <span>{writeFileBody.errorMessage}</span>
</div>
- {:else if meta}
+ {:else if writeFileBody?.content !== undefined}
<SyntaxHighlightedCode
- code={writeFileBody?.content ?? ''}
- language={meta.language}
+ code={writeFileBody.content}
+ language={writeFileBody.language}
maxHeight={MAX_HEIGHT_CODE_BLOCK}
streaming={ctx.isCodeStreaming}
/>
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
- {#if meta.resultMessage}
- {meta.resultMessage}{meta.bytesWritten != null ? RESULT_STAT_SEPARATOR : ''}{/if}
+ {#if writeFileBody.resultMessage}
+ {writeFileBody.resultMessage}{writeFileBody.bytesWritten != null
+ ? RESULT_STAT_SEPARATOR
+ : ''}{/if}
- {#if meta.bytesWritten != null}
- <span class="font-mono">{meta.bytesWritten}</span>
+ {#if writeFileBody.bytesWritten != null}
+ <span class="font-mono">{writeFileBody.bytesWritten}</span>
bytes
{/if}
</div>
+ {:else if section.toolArgs}
+ <!-- no content arg: not streamed yet, or a same-named tool with other arg names -->
+ <SyntaxHighlightedCode
+ code={formatJsonPretty(section.toolArgs)}
+ language={FileTypeText.JSON}
+ maxHeight={MAX_HEIGHT_CODE_BLOCK}
+ streaming={ctx.isCodeStreaming}
+ />
+ {:else if ctx.isStreaming}
+ <div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
+ Receiving arguments...
+ </div>
+ {:else}
+ <div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">No content</div>
{/if}
{/snippet}
</ToolCallBlock>
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared.ts
index 79d7c2e27..039b2634a 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared.ts
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared.ts
@@ -1,10 +1,14 @@
// Helpers shared by the per-tool meta parsers under
// `src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/`.
// Each tool needs the same first three steps (tool-name check,
-// args-present check, JSON parse) - keeping them here lets each parser
-// stay focused on its own format quirks.
+// args-present check, JSON parse), plus the same path aliases - keeping
+// them here lets each parser stay focused on its own format quirks.
-import { TOOL_ARG_STRING_FIELD_PATTERN_TEMPLATE } from '$lib/constants';
+import {
+ FILE_PATH_SEPARATOR_REGEX,
+ TOOL_ARG_PATH_KEYS,
+ TOOL_ARG_STRING_FIELD_PATTERN_TEMPLATE
+} from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/types/agentic';
import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
@@ -68,6 +72,34 @@ export function extractToolArgString(
return undefined;
}
+/**
+ * File name shown in a block title, from a tool-call path. Undefined when
+ * the call carries no path; the title then shows the tool label alone.
+ */
+export function fileNameFromPath(filePath: string | undefined): string | undefined {
+ if (!filePath) return undefined;
+
+ return filePath.split(FILE_PATH_SEPARATOR_REGEX).pop() || filePath;
+}
+
+/**
+ * Read the path argument out of an already parsed args object, checking
+ * the aliases in `TOOL_ARG_PATH_KEYS`. Returns undefined when the call
+ * carries no usable path - the path is display-only, so callers must
+ * still render whatever else the call streamed.
+ */
+export function extractToolArgPath(args?: Record<string, unknown> | null): string | undefined {
+ if (!args) return undefined;
+
+ for (const key of TOOL_ARG_PATH_KEYS) {
+ const value = args[key];
+
+ if (typeof value === 'string' && value) return value;
+ }
+
+ return undefined;
+}
+
/**
* Parse a section's toolArgs against an expected tool name. Returns
* `null` when:
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts
index d711466cb..2bbb6bab5 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file.ts
@@ -1,10 +1,16 @@
// Meta parser for `edit_file` tool calls. Reads the file path and the
// array of edits from the streamed args (partial JSON for incremental
// rendering), plus the result blob for `result` / `edits_applied` /
-// `error` fields.
-
-import { extractToolArgString, parseToolArgs } from './_shared';
-import { FILE_PATH_SEPARATOR_REGEX, TOOL_ARG_PATH_KEYS } from '$lib/constants';
+// `error` fields. The path is display-only (block title), so a call
+// that has not streamed one still shows its diffs.
+
+import {
+ extractToolArgPath,
+ extractToolArgString,
+ fileNameFromPath,
+ parseToolArgs
+} from './_shared';
+import { TOOL_ARG_PATH_KEYS } from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection, EditFileEdit, EditFileMeta, EditFileTitleMeta } from '$lib/types';
import { tryParseToolResultObject } from '$lib/utils';
@@ -14,11 +20,8 @@ export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null
if (!args) return null;
- const rawPath = args.path ?? args.file_path ?? args.filePath;
-
- if (typeof rawPath !== 'string' || !rawPath) return null;
-
- const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
+ const filePath = extractToolArgPath(args);
+ const fileName = fileNameFromPath(filePath);
// Filter the streamed edits array strictly: each entry must be an
// object with a non-empty `old_text`. Edits without an old_text
// would diff against empty and render as a full re-write.
@@ -61,7 +64,7 @@ export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null
editsApplied,
errorMessage,
fileName,
- filePath: rawPath,
+ filePath,
resultMessage
};
}
@@ -75,18 +78,15 @@ export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null
export function parseEditFileTitleMeta(section: AgenticSection): EditFileTitleMeta | null {
if (section.toolName !== BuiltInTool.SERVER_EDIT_FILE || !section.toolArgs) return null;
- let rawPath: string | undefined = extractToolArgString(section.toolArgs, TOOL_ARG_PATH_KEYS);
+ let filePath = extractToolArgString(section.toolArgs, TOOL_ARG_PATH_KEYS);
- if (!rawPath) {
- const args = parseToolArgs(BuiltInTool.SERVER_EDIT_FILE, section, { partial: true });
- const fallbackPath = args?.path ?? args?.file_path ?? args?.filePath;
-
- if (typeof fallbackPath === 'string' && fallbackPath) rawPath = fallbackPath;
+ if (!filePath) {
+ filePath = extractToolArgPath(
+ parseToolArgs(BuiltInTool.SERVER_EDIT_FILE, section, { partial: true })
+ );
}
- if (!rawPath) return null;
-
- const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
+ const fileName = fileNameFromPath(filePath);
const resultObj = tryParseToolResultObject(section.toolResult);
let resultMessage: string | undefined;
@@ -105,5 +105,5 @@ export function parseEditFileTitleMeta(section: AgenticSection): EditFileTitleMe
}
}
- return { editsApplied, errorMessage, fileName, filePath: rawPath, resultMessage };
+ return { editsApplied, errorMessage, fileName, filePath, resultMessage };
}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command.ts
index 7cf767535..2371c11e5 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command.ts
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command.ts
@@ -1,5 +1,7 @@
// Meta parser for `exec_shell_command` tool calls. Surfaces the
// command text from args `command` / `cmd` / `shell_command` aliases.
+// Args are parsed partially so the title can render the command while
+// it is still streaming.
// The exit-status and error parsing live in their own utilities
// (`parse-exec-shell-status.ts` / `parse-exec-shell-error.ts`) - this
// file only deals with what's strictly about *calling* the tool, since
@@ -14,7 +16,7 @@ export type ExecShellCommandMeta = {
};
export function parseExecShellCommandMeta(section: AgenticSection): ExecShellCommandMeta | null {
- const args = parseToolArgs(BuiltInTool.SERVER_EXEC_SHELL_COMMAND, section);
+ const args = parseToolArgs(BuiltInTool.SERVER_EXEC_SHELL_COMMAND, section, { partial: true });
if (!args) return null;
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts
index 4a8e1a9c9..e8917845a 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file.ts
@@ -1,30 +1,40 @@
// Meta parser for `write_file` tool calls. Reads the path/content from
// the streamed args (partial JSON so we can render before the call
// finishes) and surfaces `bytes`, `result`, and `error` from the
-// result blob.
+// result blob. The path is display-only (title + highlight language),
+// so a call that has not streamed one still shows its content.
-import { extractToolArgString, parseToolArgs } from './_shared';
-import { CODE_BLOCK, FILE_PATH_SEPARATOR_REGEX, TOOL_ARG_PATH_KEYS } from '$lib/constants';
+import {
+ extractToolArgPath,
+ extractToolArgString,
+ fileNameFromPath,
+ parseToolArgs
+} from './_shared';
+import { CODE_BLOCK, TOOL_ARG_PATH_KEYS } from '$lib/constants';
import { BuiltInTool } from '$lib/enums';
import type { AgenticSection, WriteFileMeta, WriteFileTitleMeta } from '$lib/types';
import { getFileTypeByExtension, tryParseToolResultObject } from '$lib/utils';
+// Plain text when the call carries no path, or one with no known extension.
+function languageForPath(filePath: string | undefined): string {
+ if (!filePath) return CODE_BLOCK.DEFAULT_LANGUAGE;
+
+ return (
+ getFileTypeByExtension(filePath)?.replace(CODE_BLOCK.TEXT_LANGUAGE_PREFIX_REGEX, '') ??
+ CODE_BLOCK.DEFAULT_LANGUAGE
+ );
+}
+
export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null {
const args = parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true });
if (!args) return null;
- // Tool contracts drifted over time: some models emit `path`,
- // others `file_path` / `filePath`. Accept all three.
- const rawPath = args.path ?? args.file_path ?? args.filePath;
-
- if (typeof rawPath !== 'string' || !rawPath) return null;
-
- const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
- const content = typeof args.content === 'string' ? args.content : '';
- const language =
- getFileTypeByExtension(rawPath)?.replace(CODE_BLOCK.TEXT_LANGUAGE_PREFIX_REGEX, '') ??
- CODE_BLOCK.DEFAULT_LANGUAGE;
+ const filePath = extractToolArgPath(args);
+ const fileName = fileNameFromPath(filePath);
+ // undefined, not '', so an intentionally empty write still renders
+ const content = typeof args.content === 'string' ? args.content : undefined;
+ const language = languageForPath(filePath);
const resultObj = tryParseToolResultObject(section.toolResult);
const bytesWritten =
resultObj && Number.isFinite(Number(resultObj.bytes)) ? Number(resultObj.bytes) : undefined;
@@ -36,7 +46,7 @@ export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | nul
content,
errorMessage,
fileName,
- filePath: rawPath,
+ filePath,
language,
resultMessage
};
@@ -51,21 +61,14 @@ export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | nul
export function parseWriteFileTitleMeta(section: AgenticSection): WriteFileTitleMeta | null {
if (section.toolName !== BuiltInTool.SERVER_WRITE_FILE || !section.toolArgs) return null;
- let rawPath: string | undefined = extractToolArgString(section.toolArgs, TOOL_ARG_PATH_KEYS);
+ let filePath = extractToolArgString(section.toolArgs, TOOL_ARG_PATH_KEYS);
- if (!rawPath) {
- const args = parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true });
- const fallbackPath = args?.path ?? args?.file_path ?? args?.filePath;
-
- if (typeof fallbackPath === 'string' && fallbackPath) rawPath = fallbackPath;
+ if (!filePath) {
+ filePath = extractToolArgPath(
+ parseToolArgs(BuiltInTool.SERVER_WRITE_FILE, section, { partial: true })
+ );
}
- if (!rawPath) return null;
-
- const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
- const language =
- getFileTypeByExtension(rawPath)?.replace(CODE_BLOCK.TEXT_LANGUAGE_PREFIX_REGEX, '') ??
- CODE_BLOCK.DEFAULT_LANGUAGE;
const resultObj = tryParseToolResultObject(section.toolResult);
const bytesWritten =
resultObj && Number.isFinite(Number(resultObj.bytes)) ? Number(resultObj.bytes) : undefined;
@@ -75,9 +78,9 @@ export function parseWriteFileTitleMeta(section: AgenticSection): WriteFileTitle
return {
bytesWritten,
errorMessage,
- fileName,
- filePath: rawPath,
- language,
+ fileName: fileNameFromPath(filePath),
+ filePath,
+ language: languageForPath(filePath),
resultMessage
};
}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
index ea9428e07..b6138dfc0 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
@@ -8,14 +8,14 @@
MarkdownContent
} from '$lib/components/app';
import { AgenticSectionType, ChatMessageStatsView, ToolPermissionDecision } from '$lib/enums';
- import { agenticStore, settingsStore } from '$lib/stores';
+ import { agenticStore, conversationsStore, settingsStore } from '$lib/stores';
import type {
AgenticSection,
ChatMessageAgenticTimings,
ChatMessageAgenticTurnStats,
DatabaseMessage
} from '$lib/types';
- import { deriveAgenticSections } from '$lib/utils';
+ import { deriveAgenticSections, isSectionExpandedByDefault } from '$lib/utils';
interface Props {
message: DatabaseMessage;
@@ -34,6 +34,7 @@
let expandedStates: Record<number, boolean> = $state({});
const showThoughtInProgress = $derived(Boolean(settingsStore.config.showThoughtInProgress));
+ const showToolCallInProgress = $derived(Boolean(settingsStore.config.showToolCallInProgress));
const alwaysShowToolCallContent = $derived(
Boolean(settingsStore.config.alwaysShowToolCallContent)
);
@@ -95,6 +96,14 @@
isStreaming ? agenticStore.getExecutingToolCallId(message.convId) : null
);
+ // Cwd the agentic loop passes to tool calls in this conversation; the exec
+ // block shows it until the result message persists the real one.
+ const conversationCwd = $derived(
+ conversationsStore.activeConversation?.id === message.convId
+ ? conversationsStore.activeConversation?.cwd
+ : undefined
+ );
+
type TurnGroup = {
sections: AgenticSection[];
flatIndices: number[];
@@ -132,28 +141,17 @@
return groups;
});
- function getDefaultExpanded(section: AgenticSection): boolean {
- if (
- section.type === AgenticSectionType.TOOL_CALL ||
- section.type === AgenticSectionType.TOOL_CALL_PENDING ||
- section.type === AgenticSectionType.TOOL_CALL_STREAMING
- ) {
- return alwaysShowToolCallContent;
- }
-
- if (section.type === AgenticSectionType.REASONING_PENDING) {
- return showThoughtInProgress;
- }
-
- return false;
- }
-
function isExpanded(index: number, section: AgenticSection): boolean {
if (expandedStates[index] !== undefined) {
return expandedStates[index];
}
- return getDefaultExpanded(section);
+ return isSectionExpandedByDefault(section, {
+ alwaysShowToolCallContent,
+ executingToolCallId: currentlyExecutingToolCallId,
+ showThoughtInProgress,
+ showToolCallInProgress
+ });
}
function toggleExpanded(index: number, section: AgenticSection) {
@@ -190,6 +188,7 @@
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING || section.type === AgenticSectionType.TOOL_CALL_STREAMING}
<ChatMessageToolCallBlock
attachments={section.toolResultExtras}
+ {conversationCwd}
isExecuting={section.toolCallId !== undefined &&
section.toolCallId === currentlyExecutingToolCallId}
{isStreaming}
diff --git a/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte b/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte
index 610923b9c..ce1d6379d 100644
--- a/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte
+++ b/tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte
@@ -98,9 +98,17 @@
<!-- See CollapsibleContentBlock: bits-ui keeps closed content mounted, which
makes a collapsed tool result re-render on every streamed token. -->
{#if open}
- <div class="p-3 pt-1">
+ <div class="terminal-body p-3 pt-1">
{@render children()}
</div>
{/if}
</Collapsible.Content>
</Collapsible.Root>
+
+<style>
+ /* A body that renders no element (exec block while its args stream)
+ must not leave its padding as a gap under the title. */
+ .terminal-body:not(:has(*)) {
+ padding: 0;
+ }
+</style>
diff --git a/tools/ui/src/lib/constants/settings-keys.constants.ts b/tools/ui/src/lib/constants/settings-keys.constants.ts
index d4ae8318c..0dcca2ace 100644
--- a/tools/ui/src/lib/constants/settings-keys.constants.ts
+++ b/tools/ui/src/lib/constants/settings-keys.constants.ts
@@ -65,6 +65,7 @@ export const SETTINGS_KEYS = {
SHOW_RAW_OUTPUT_SWITCH: 'showRawOutputSwitch',
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
+ SHOW_TOOL_CALL_IN_PROGRESS: 'showToolCallInProgress',
SYMBOLIC_MATH_ENABLED: 'symbolicMathEnabled',
SYSTEM_MESSAGE: 'systemMessage',
// Sampling
diff --git a/tools/ui/src/lib/constants/settings.constants.ts b/tools/ui/src/lib/constants/settings.constants.ts
index 9d00ac9ef..4c0d759f6 100644
--- a/tools/ui/src/lib/constants/settings.constants.ts
+++ b/tools/ui/src/lib/constants/settings.constants.ts
@@ -227,9 +227,16 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Show thought in progress',
type: SettingsFieldType.CHECKBOX
},
+ {
+ defaultValue: true,
+ help: 'Expand tool call details while the call is being generated and executed, and collapse them again once its result is in. Mirrors "Show thought in progress". Tool calls you expand or collapse by hand keep your choice.',
+ key: SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS,
+ label: 'Show tool call in progress',
+ type: SettingsFieldType.CHECKBOX
+ },
{
defaultValue: false,
- help: 'Automatically expand tool call details while executing and keep them expanded after completion.',
+ help: 'Keep tool call details expanded after completion. Takes precedence over "Show tool call in progress", which only covers the in-flight phases.',
key: SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT,
label: 'Always show tool call content',
type: SettingsFieldType.CHECKBOX
diff --git a/tools/ui/src/lib/types/tools.d.ts b/tools/ui/src/lib/types/tools.d.ts
index fa8963bd1..013e1930c 100644
--- a/tools/ui/src/lib/types/tools.d.ts
+++ b/tools/ui/src/lib/types/tools.d.ts
@@ -33,10 +33,12 @@ export interface ToolGroup {
}
export interface WriteFileMeta {
- fileName: string;
- filePath: string;
+ /** Undefined until the call streams a path; content renders without it. */
+ fileName?: string;
+ filePath?: string;
language: string;
- content: string;
+ /** Undefined until the args carry a content string; an empty write is ''. */
+ content?: string;
bytesWritten?: number;
resultMessage?: string;
errorMessage?: string;
@@ -46,8 +48,8 @@ export interface WriteFileMeta {
* ( with the embedded file content ) stays body-only so collapsed blocks
* never parse the content blob. */
export interface WriteFileTitleMeta {
- fileName: string;
- filePath: string;
+ fileName?: string;
+ filePath?: string;
language: string;
bytesWritten?: number;
resultMessage?: string;
@@ -60,8 +62,9 @@ export interface EditFileEdit {
}
export interface EditFileMeta {
- fileName: string;
- filePath: string;
+ /** Undefined until the call streams a path; the edits render without it. */
+ fileName?: string;
+ filePath?: string;
edits: EditFileEdit[];
resultMessage?: string;
editsApplied?: number;
@@ -72,8 +75,8 @@ export interface EditFileMeta {
* ( with the embedded edit strings ) stays body-only so collapsed blocks
* never parse the args blob. */
export interface EditFileTitleMeta {
- fileName: string;
- filePath: string;
+ fileName?: string;
+ filePath?: string;
resultMessage?: string;
editsApplied?: number;
errorMessage?: string;
diff --git a/tools/ui/src/lib/utils/agentic.ts b/tools/ui/src/lib/utils/agentic.ts
index 28b3f43ee..f4d409721 100644
--- a/tools/ui/src/lib/utils/agentic.ts
+++ b/tools/ui/src/lib/utils/agentic.ts
@@ -540,6 +540,49 @@ export function hasAgenticContent(
return toolMessages.length > 0;
}
+/**
+ * Default expansion of a section, used until the user toggles it by hand.
+ *
+ * @param section - The section to judge
+ * @param flags - Display settings plus the tool call the agentic loop is running now.
+ * A running call outranks its section type: `exec_shell_command` persists an empty
+ * result before reading output, so a live call can already be TOOL_CALL.
+ */
+export function isSectionExpandedByDefault(
+ section: AgenticSection,
+ {
+ alwaysShowToolCallContent,
+ executingToolCallId,
+ showThoughtInProgress,
+ showToolCallInProgress
+ }: {
+ alwaysShowToolCallContent: boolean;
+ executingToolCallId?: string | null;
+ showThoughtInProgress: boolean;
+ showToolCallInProgress: boolean;
+ }
+): boolean {
+ const isRunning = section.toolCallId !== undefined && section.toolCallId === executingToolCallId;
+
+ if (
+ isRunning ||
+ section.type === AgenticSectionType.TOOL_CALL_PENDING ||
+ section.type === AgenticSectionType.TOOL_CALL_STREAMING
+ ) {
+ return showToolCallInProgress || alwaysShowToolCallContent;
+ }
+
+ if (section.type === AgenticSectionType.TOOL_CALL) {
+ return alwaysShowToolCallContent;
+ }
+
+ if (section.type === AgenticSectionType.REASONING_PENDING) {
+ return showThoughtInProgress;
+ }
+
+ return false;
+}
+
/**
* Decide how a Continue click on messages[idx] should resume generation.
* Pure function over the persisted history snapshot.
diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index 5b46bedef..a5f2af55f 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -282,6 +282,7 @@ export {
parseToolResultWithMedia,
splitSearchSummaryList,
hasAgenticContent,
+ isSectionExpandedByDefault,
classifyToolResult,
classifyContinueIntent
} from './agentic';
diff --git a/tools/ui/tests/client/exec-shell-command-streaming.svelte.test.ts b/tools/ui/tests/client/exec-shell-command-streaming.svelte.test.ts
new file mode 100644
index 000000000..87668c0e3
--- /dev/null
+++ b/tools/ui/tests/client/exec-shell-command-streaming.svelte.test.ts
@@ -0,0 +1,216 @@
+// Guards the exec_shell_command title while the call is still being
+// generated: the command must stream into the terminal title from the
+// first character (args arrive as partial JSON), with a blinking cursor
+// while the args are incomplete, and the conversation cwd standing in
+// for the x-tool-cwd value that only arrives with the result message.
+
+import ChatMessageToolCallBlockExecShellCommand from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte';
+import ChatMessageAgenticContent from '$lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte';
+import { SETTINGS_KEYS } from '$lib/constants';
+import { AgenticSectionType, BuiltInTool, MessageRole, MessageType } from '$lib/enums';
+import { conversationsStore, settingsStore } from '$lib/stores';
+import type { AgenticSection, DatabaseMessage } from '$lib/types';
+import { tick } from 'svelte';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+const CONV_ID = 'conv-1';
+const TOOL_CALL_ID = 'call-1';
+const TOOL_NAME = BuiltInTool.SERVER_EXEC_SHELL_COMMAND;
+const CWD = '/home/user/project';
+
+function messageWithArgs(args: string): DatabaseMessage {
+ return {
+ children: [],
+ content: '',
+ convId: CONV_ID,
+ id: 'assistant-1',
+ parent: null,
+ role: MessageRole.ASSISTANT,
+ timestamp: 0,
+ toolCalls: JSON.stringify([
+ { function: { arguments: args, name: TOOL_NAME }, id: TOOL_CALL_ID }
+ ]),
+ type: MessageType.TEXT
+ };
+}
+
+function toolResultMessage(content: string, toolCwd?: string): DatabaseMessage {
+ return {
+ children: [],
+ content,
+ convId: CONV_ID,
+ id: 'tool-1',
+ parent: null,
+ role: MessageRole.TOOL,
+ timestamp: 0,
+ toolCallId: TOOL_CALL_ID,
+ toolCalls: '',
+ toolCwd,
+ type: MessageType.TEXT
+ };
+}
+
+function triggerText(container: Element): string {
+ return container.querySelector('[data-slot="collapsible-trigger"]')?.textContent ?? '';
+}
+
+function hasCursor(container: Element): boolean {
+ return container.querySelector('.exec-cursor') !== null;
+}
+
+describe('exec_shell_command streaming title', () => {
+ beforeEach(() => {
+ settingsStore.updateMultipleConfig({
+ [SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT]: false,
+ [SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS]: true
+ });
+ conversationsStore.activeConversation = {
+ currNode: null,
+ cwd: CWD,
+ id: CONV_ID,
+ lastModified: 0,
+ name: 'test'
+ };
+ });
+
+ afterEach(() => {
+ conversationsStore.activeConversation = null;
+ conversationsStore.activeMessages = [];
+ });
+
+ it('streams the partial command into the title with a cursor', async () => {
+ const { container } = render(ChatMessageAgenticContent, {
+ isStreaming: true,
+ message: messageWithArgs('{"command":"git sta'),
+ toolMessages: []
+ });
+
+ await tick();
+
+ const title = triggerText(container);
+
+ expect(title).toContain('git sta');
+ expect(title).toContain('$');
+ expect(title).toContain(CWD);
+ expect(hasCursor(container)).toBe(true);
+ });
+
+ it('drops the cursor once the args JSON is complete', async () => {
+ const screen = render(ChatMessageAgenticContent, {
+ isStreaming: true,
+ message: messageWithArgs('{"command":"git sta'),
+ toolMessages: []
+ });
+
+ await tick();
+
+ expect(hasCursor(screen.container)).toBe(true);
+
+ screen.rerender({
+ isStreaming: true,
+ message: messageWithArgs('{"command":"git status"}'),
+ toolMessages: []
+ });
+ await tick();
+
+ expect(triggerText(screen.container)).toContain('git status');
+ expect(hasCursor(screen.container)).toBe(false);
+ });
+
+ it('switches the prompt from the conversation cwd to the persisted one', async () => {
+ const screen = render(ChatMessageAgenticContent, {
+ isStreaming: true,
+ message: messageWithArgs('{"command":"git status"}'),
+ toolMessages: []
+ });
+
+ await tick();
+
+ expect(triggerText(screen.container)).toContain(CWD);
+
+ // Result message lands: its x-tool-cwd takes over the prompt.
+ screen.rerender({
+ isStreaming: true,
+ message: messageWithArgs('{"command":"git status"}'),
+ toolMessages: [toolResultMessage('', '/other/dir')]
+ });
+ await tick();
+
+ expect(triggerText(screen.container)).toContain('/other/dir');
+ expect(triggerText(screen.container)).not.toContain(CWD);
+ });
+
+ it('shows the executing label only after the command finishes streaming', async () => {
+ const screen = render(ChatMessageAgenticContent, {
+ isStreaming: true,
+ message: messageWithArgs('{"command":"git sta'),
+ toolMessages: []
+ });
+
+ await tick();
+
+ // While the args stream, the title cursor alone signals activity.
+ expect(screen.container.textContent).not.toContain('Executing command...');
+
+ screen.rerender({
+ isStreaming: true,
+ message: messageWithArgs('{"command":"git status"}'),
+ toolMessages: []
+ });
+ await tick();
+
+ expect(screen.container.textContent).toContain('Executing command...');
+ });
+
+ it('shows Executing command... while executing before the first output chunk', async () => {
+ // The result message is created empty right before execution, so
+ // the block must not go blank while the process produces nothing.
+ const section: AgenticSection = {
+ content: '',
+ toolArgs: '{"command":"sleep 1"}',
+ toolCallId: TOOL_CALL_ID,
+ toolName: TOOL_NAME,
+ toolResult: '',
+ type: AgenticSectionType.TOOL_CALL
+ };
+ const { container } = render(ChatMessageToolCallBlockExecShellCommand, {
+ isExecuting: true,
+ isStreaming: true,
+ open: true,
+ section
+ });
+
+ await tick();
+
+ expect(container.textContent).toContain('Executing command...');
+ });
+
+ it('updates the title when the store mutates toolCalls in place, as the stream does', async () => {
+ // The live flow never swaps the message object: onToolCallsStreaming
+ // writes growing toolCalls strings into the same store row per delta.
+ conversationsStore.activeMessages = [messageWithArgs('')];
+
+ const screen = render(ChatMessageAgenticContent, {
+ isStreaming: true,
+ message: conversationsStore.activeMessages[0]!,
+ toolMessages: []
+ });
+
+ await tick();
+
+ const idx = conversationsStore.findMessageIndex('assistant-1');
+
+ for (const args of ['{"command', '{"command":"git', '{"command":"git sta']) {
+ conversationsStore.updateMessageAtIndex(idx, {
+ toolCalls: JSON.stringify([
+ { function: { arguments: args, name: TOOL_NAME }, id: TOOL_CALL_ID }
+ ])
+ });
+ await tick();
+
+ expect(triggerText(screen.container)).toContain(args.slice('{"command":"'.length));
+ expect(hasCursor(screen.container)).toBe(true);
+ }
+ });
+});
diff --git a/tools/ui/tests/client/tool-call-block-streamed-body.svelte.test.ts b/tools/ui/tests/client/tool-call-block-streamed-body.svelte.test.ts
new file mode 100644
index 000000000..1ff0f1fd7
--- /dev/null
+++ b/tools/ui/tests/client/tool-call-block-streamed-body.svelte.test.ts
@@ -0,0 +1,82 @@
+// A file tool call must show what it streamed even when it carries no
+// path: the path only names the file in the block title, so a call that
+// streams its body first (or a same-named tool with other arg names) used
+// to render an empty block.
+
+import ChatMessageToolCallBlockEditFile from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte';
+import ChatMessageToolCallBlockWriteFile from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte';
+import { AgenticSectionType, BuiltInTool } from '$lib/enums';
+import type { AgenticSection } from '$lib/types';
+import { tick } from 'svelte';
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+
+const WRITE_FILE_BODY = 'body streamed without a path';
+const EDIT_FILE_NEW_TEXT = 'replacement text';
+
+function makeSection(toolName: BuiltInTool, toolArgs: string): AgenticSection {
+ return {
+ content: '',
+ toolArgs,
+ toolName,
+ toolResult: undefined,
+ type: AgenticSectionType.TOOL_CALL
+ };
+}
+
+describe('file tool calls render their body without a path', () => {
+ it('write_file shows the content it streamed', async () => {
+ render(ChatMessageToolCallBlockWriteFile, {
+ isStreaming: true,
+ open: true,
+ section: makeSection(BuiltInTool.SERVER_WRITE_FILE, `{"content":"${WRITE_FILE_BODY}"`)
+ });
+
+ await tick();
+
+ expect(document.body.textContent).toContain(WRITE_FILE_BODY);
+ });
+
+ it('edit_file shows the diff it streamed', async () => {
+ render(ChatMessageToolCallBlockEditFile, {
+ isStreaming: true,
+ open: true,
+ section: makeSection(
+ BuiltInTool.SERVER_EDIT_FILE,
+ `{"edits":[{"old_text":"stale text","new_text":"${EDIT_FILE_NEW_TEXT}"}]}`
+ )
+ });
+
+ await tick();
+
+ expect(document.body.textContent).toContain(EDIT_FILE_NEW_TEXT);
+ expect(document.body.textContent).toContain('stale text');
+ });
+
+ it('write_file falls back to the raw args when no known content key is present', async () => {
+ render(ChatMessageToolCallBlockWriteFile, {
+ isStreaming: false,
+ open: true,
+ section: makeSection(BuiltInTool.SERVER_WRITE_FILE, '{"contents":"x"}')
+ });
+
+ await tick();
+
+ expect(document.body.textContent).toContain('contents');
+ });
+
+ it('write_file renders an empty code block for a finished empty write', async () => {
+ // content of '' used to fall through to the raw-args fallback
+ const section = makeSection(BuiltInTool.SERVER_WRITE_FILE, '{"content":""}');
+
+ section.toolResult = '{"result":"File written","bytes":0}';
+
+ render(ChatMessageToolCallBlockWriteFile, { isStreaming: false, open: true, section });
+
+ await tick();
+
+ expect(document.querySelector('pre code.hljs')).toBeTruthy();
+ expect(document.body.textContent).not.toContain('"content"');
+ expect(document.body.textContent).toMatch(/0\s*bytes/);
+ });
+});
diff --git a/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts b/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts
new file mode 100644
index 000000000..af32aef3c
--- /dev/null
+++ b/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts
@@ -0,0 +1,212 @@
+// Guards the two Settings > Display toggles that drive the default expansion of
+// a tool call block.
+//
+// "Show tool call in progress" mirrors "Show thought in progress": a call is
+// expanded by default only while it is in flight and folds back on its own once
+// the result lands. "Always show tool call content" is the one that keeps
+// finished calls open and wins on its own. A manual toggle always overrides the
+// default, in both directions.
+
+import ChatMessageAgenticContent from '$lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte';
+import { SETTINGS_KEYS } from '$lib/constants';
+import { MessageRole, MessageType } from '$lib/enums';
+import { settingsStore } from '$lib/stores';
+import type { DatabaseMessage } from '$lib/types';
+import { tick } from 'svelte';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { userEvent } from 'vitest/browser';
+import { render } from 'vitest-browser-svelte';
+
+const TOOL_NAME = 'custom_tool';
+const TOOL_CALL_ID = 'call-1';
+// Both markers sit inside the collapsible body, which CollapsibleContentBlock
+// only mounts while open - so their presence is exactly "the block is expanded".
+const PENDING_BODY = 'Waiting for result...';
+const RESULT_BODY = 'marker-tool-output';
+const TRIGGER_SELECTOR = '[data-slot="collapsible-trigger"]';
+
+function assistantMessage(): DatabaseMessage {
+ return {
+ children: [],
+ content: '',
+ convId: 'conv-1',
+ id: 'assistant-1',
+ parent: null,
+ role: MessageRole.ASSISTANT,
+ timestamp: 0,
+ toolCalls: JSON.stringify([
+ { function: { arguments: '{"path":"a.txt"}', name: TOOL_NAME }, id: TOOL_CALL_ID }
+ ]),
+ type: MessageType.TEXT
+ };
+}
+
+function toolResultMessage(): DatabaseMessage {
+ return {
+ ...assistantMessage(),
+ content: RESULT_BODY,
+ id: 'tool-1',
+ role: MessageRole.TOOL,
+ toolCallId: TOOL_CALL_ID
+ };
+}
+
+function setSettings({
+ alwaysShow,
+ inProgress
+}: {
+ alwaysShow: boolean;
+ inProgress: boolean;
+}): void {
+ settingsStore.updateMultipleConfig({
+ [SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT]: alwaysShow,
+ [SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS]: inProgress
+ });
+}
+
+function textOf(container: Element): string {
+ return container.textContent ?? '';
+}
+
+async function collapseBlock(screen: { container: Element }): Promise<void> {
+ const trigger = screen.container.querySelector(TRIGGER_SELECTOR);
+
+ if (!(trigger instanceof HTMLButtonElement)) throw new Error('trigger not found');
+
+ await userEvent.click(trigger);
+ await tick();
+}
+
+describe('Show tool call in progress', () => {
+ beforeEach(() => {
+ setSettings({ alwaysShow: false, inProgress: false });
+ });
+
+ it('expands a call that is still executing when the setting is on', async () => {
+ setSettings({ alwaysShow: false, inProgress: true });
+
+ const { container } = render(ChatMessageAgenticContent, {
+ isStreaming: true,
+ message: assistantMessage(),
+ toolMessages: []
+ });
+
+ await tick();
+
+ expect(textOf(container)).toContain(PENDING_BODY);
+ });
+
+ it('keeps the call collapsed while executing when the setting is off', async () => {
+ const { container } = render(ChatMessageAgenticContent, {
+ isStreaming: true,
+ message: assistantMessage(),
+ toolMessages: []
+ });
+
+ await tick();
+
+ // The header is still there, only the body stays hidden.
+ expect(textOf(container)).toContain(TOOL_NAME);
+ expect(textOf(container)).not.toContain(PENDING_BODY);
+ });
+
+ it('folds the call back when its result arrives', async () => {
+ setSettings({ alwaysShow: false, inProgress: true });
+
+ const screen = render(ChatMessageAgenticContent, {
+ isStreaming: true,
+ message: assistantMessage(),
+ toolMessages: []
+ });
+
+ await tick();
+
+ // Open while it waits for the result.
+ expect(textOf(screen.container)).toContain(PENDING_BODY);
+
+ // Same block, now finished: it must close on its own, no toggle given.
+ screen.rerender({
+ isStreaming: true,
+ message: assistantMessage(),
+ toolMessages: [toolResultMessage()]
+ });
+ await tick();
+
+ expect(textOf(screen.container)).toContain(TOOL_NAME);
+ expect(textOf(screen.container)).not.toContain(PENDING_BODY);
+ expect(textOf(screen.container)).not.toContain(RESULT_BODY);
+ });
+
+ it('keeps the manual choice once the result arrives', async () => {
+ setSettings({ alwaysShow: false, inProgress: true });
+
+ const screen = render(ChatMessageAgenticContent, {
+ isStreaming: true,
+ message: assistantMessage(),
+ toolMessages: []
+ });
+
+ await tick();
+
+ // Collapsed by hand while running, so the result must not reopen it.
+ await collapseBlock(screen);
+
+ screen.rerender({
+ isStreaming: true,
+ message: assistantMessage(),
+ toolMessages: [toolResultMessage()]
+ });
+ await tick();
+
+ expect(textOf(screen.container)).toContain(TOOL_NAME);
+ expect(textOf(screen.container)).not.toContain(RESULT_BODY);
+ });
+
+ it('still expands a finished call when "always show" is on', async () => {
+ setSettings({ alwaysShow: true, inProgress: false });
+
+ const { container } = render(ChatMessageAgenticContent, {
+ isStreaming: true,
+ message: assistantMessage(),
+ toolMessages: [toolResultMessage()]
+ });
+
+ await tick();
+
+ expect(textOf(container)).toContain(RESULT_BODY);
+ });
+
+ it('lets a manual collapse win over the in-progress default', async () => {
+ setSettings({ alwaysShow: false, inProgress: true });
+
+ const screen = render(ChatMessageAgenticContent, {
+ isStreaming: true,
+ message: assistantMessage(),
+ toolMessages: []
+ });
+
+ await tick();
+
+ expect(textOf(screen.container)).toContain(PENDING_BODY);
+
+ await collapseBlock(screen);
+
+ expect(textOf(screen.container)).not.toContain(PENDING_BODY);
+ });
+
+ it('lets a manual expand win over the collapsed default', async () => {
+ const screen = render(ChatMessageAgenticContent, {
+ isStreaming: true,
+ message: assistantMessage(),
+ toolMessages: []
+ });
+
+ await tick();
+
+ expect(textOf(screen.container)).not.toContain(PENDING_BODY);
+
+ await collapseBlock(screen);
+
+ expect(textOf(screen.container)).toContain(PENDING_BODY);
+ });
+});
diff --git a/tools/ui/tests/unit/agentic-section-expansion.test.ts b/tools/ui/tests/unit/agentic-section-expansion.test.ts
new file mode 100644
index 000000000..1b598f616
--- /dev/null
+++ b/tools/ui/tests/unit/agentic-section-expansion.test.ts
@@ -0,0 +1,122 @@
+// Truth table for the default expansion of agentic sections - what a tool call
+// block does before the user touches it.
+//
+// The interesting row is a call the agentic loop reports as running while its
+// section is already TOOL_CALL: exec_shell_command persists an empty result
+// before any output is read, so the section type alone cannot tell a live call
+// from a finished one.
+
+import { AgenticSectionType } from '$lib/enums';
+import type { AgenticSection } from '$lib/types';
+import { isSectionExpandedByDefault } from '$lib/utils';
+import { describe, expect, it } from 'vitest';
+
+const CALL_ID = 'call-1';
+
+function section(overrides: Partial<AgenticSection> = {}): AgenticSection {
+ return { content: '', type: AgenticSectionType.TOOL_CALL, ...overrides };
+}
+
+function expanded(section: AgenticSection, flags: Record<string, unknown> = {}): boolean {
+ return isSectionExpandedByDefault(section, {
+ alwaysShowToolCallContent: false,
+ executingToolCallId: null,
+ showThoughtInProgress: false,
+ showToolCallInProgress: false,
+ ...flags
+ });
+}
+
+describe('isSectionExpandedByDefault', () => {
+ it('expands a call whose arguments are streaming when the setting is on', () => {
+ const streaming = section({
+ toolCallId: CALL_ID,
+ toolName: 'read_file',
+ type: AgenticSectionType.TOOL_CALL_STREAMING
+ });
+
+ expect(expanded(streaming, { showToolCallInProgress: true })).toBe(true);
+ expect(expanded(streaming)).toBe(false);
+ });
+
+ it('expands a call waiting for its result when the setting is on', () => {
+ const pending = section({ toolCallId: CALL_ID, type: AgenticSectionType.TOOL_CALL_PENDING });
+
+ expect(expanded(pending, { showToolCallInProgress: true })).toBe(true);
+ expect(expanded(pending)).toBe(false);
+ });
+
+ it('collapses a finished call unless "always show" is on', () => {
+ const finished = section({ toolCallId: CALL_ID, toolResult: 'output' });
+
+ expect(expanded(finished, { showToolCallInProgress: true })).toBe(false);
+ expect(expanded(finished, { alwaysShowToolCallContent: true })).toBe(true);
+ });
+
+ it('keeps the running call expanded even when its result is already persisted', () => {
+ // exec_shell_command: empty result created, output still streaming
+ const live = section({ toolCallId: CALL_ID, toolResult: '' });
+
+ expect(expanded(live, { executingToolCallId: CALL_ID, showToolCallInProgress: true })).toBe(
+ true
+ );
+ });
+
+ it('does not treat another call finishing as the running one', () => {
+ const other = section({ toolCallId: 'call-2', toolResult: 'output' });
+
+ expect(expanded(other, { executingToolCallId: CALL_ID, showToolCallInProgress: true })).toBe(
+ false
+ );
+ });
+
+ it('still leaves the running call collapsed when both settings are off', () => {
+ const live = section({ toolCallId: CALL_ID, toolResult: '' });
+
+ expect(expanded(live, { executingToolCallId: CALL_ID })).toBe(false);
+ });
+
+ it('never reads a missing executing id as a running call', () => {
+ expect(expanded(section({ toolCallId: CALL_ID }), { executingToolCallId: null })).toBe(false);
+ expect(expanded(section({ type: AgenticSectionType.TEXT }), { executingToolCallId: '' })).toBe(
+ false
+ );
+ });
+
+ it('expands a running call when "always show" is on and the in-progress one is off', () => {
+ const pending = section({ toolCallId: CALL_ID, type: AgenticSectionType.TOOL_CALL_PENDING });
+
+ expect(expanded(pending, { alwaysShowToolCallContent: true })).toBe(true);
+ });
+
+ it('follows the thought setting for reasoning and never for finished thoughts', () => {
+ expect(
+ expanded(
+ { content: 'think', type: AgenticSectionType.REASONING_PENDING },
+ { showThoughtInProgress: true }
+ )
+ ).toBe(true);
+ expect(
+ expanded(
+ { content: 'think', type: AgenticSectionType.REASONING },
+ {
+ showThoughtInProgress: true
+ }
+ )
+ ).toBe(false);
+ });
+
+ it('never expands plain text', () => {
+ expect(
+ expanded(
+ { content: 'hello', type: AgenticSectionType.TEXT },
+ {
+ alwaysShowToolCallContent: true,
+ executingToolCallId: CALL_ID,
+ showThoughtInProgress: true,
+ showToolCallInProgress: true
+ }
+ )
+ ).toBe(false);
+ });
+});
diff --git a/tools/ui/tests/unit/settings-tool-call-in-progress.test.ts b/tools/ui/tests/unit/settings-tool-call-in-progress.test.ts
new file mode 100644
index 000000000..04651644a
--- /dev/null
+++ b/tools/ui/tests/unit/settings-tool-call-in-progress.test.ts
@@ -0,0 +1,45 @@
+// Guards the Settings > Display wiring of "Show tool call in progress": the
+// flat config default and its presence as a checkbox in the Display section
+// come from the settings registry, so the key/label/type/section pairing is the
+// contract the dialog renders.
+
+import {
+ SETTING_CONFIG_DEFAULT,
+ SETTINGS_CHAT_SECTIONS,
+ SETTINGS_KEYS,
+ SETTINGS_SECTION_SLUGS
+} from '$lib/constants';
+import { SettingsFieldType } from '$lib/enums';
+import { describe, expect, it } from 'vitest';
+
+function displayField(key: string) {
+ const section = SETTINGS_CHAT_SECTIONS.find((s) => s.slug === SETTINGS_SECTION_SLUGS.DISPLAY);
+
+ return section?.fields?.find((field) => field?.key === key);
+}
+
+describe('showToolCallInProgress setting', () => {
+ it('is a checkbox in the Display section', () => {
+ const field = displayField(SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS);
+
+ expect(field).toBeDefined();
+ expect(field?.type).toBe(SettingsFieldType.CHECKBOX);
+ expect(field?.label).toBe('Show tool call in progress');
+ });
+
+ it('ships enabled, like "Show thought in progress"', () => {
+ expect(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS]).toBe(true);
+ expect(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS]).toBe(true);
+ });
+
+ it('is listed next to its "always show" counterpart', () => {
+ const section = SETTINGS_CHAT_SECTIONS.find(
+ (s) => s.slug === SETTINGS_SECTION_SLUGS.DISPLAY
+ ) as (typeof SETTINGS_CHAT_SECTIONS)[number];
+ const keys = section.fields?.map((field) => field?.key) ?? [];
+ const inProgressIndex = keys.indexOf(SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS);
+
+ expect(inProgressIndex).toBeGreaterThanOrEqual(0);
+ expect(keys[inProgressIndex + 1]).toBe(SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT);
+ });
+});
diff --git a/tools/ui/tests/unit/tool-calls.test.ts b/tools/ui/tests/unit/tool-calls.test.ts
index a2274f9d2..7b01b7b26 100644
--- a/tools/ui/tests/unit/tool-calls.test.ts
+++ b/tools/ui/tests/unit/tool-calls.test.ts
@@ -1,4 +1,7 @@
-import { parseToolArgs } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
+import {
+ extractToolArgPath,
+ parseToolArgs
+} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
import {
parseEditFileMeta,
parseEditFileTitleMeta
@@ -12,6 +15,7 @@ import {
parseWriteFileMeta,
parseWriteFileTitleMeta
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file';
+import { CODE_BLOCK } from '$lib/constants';
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
import type { AgenticSection, WriteFileMeta } from '$lib/types';
import { abbreviateHome, formatCwdMessage, lastPathSegment, parseCwdMessage } from '$lib/utils';
@@ -164,6 +168,22 @@ describe('parseToolArgs (shared)', () => {
});
});
+describe('extractToolArgPath', () => {
+ it('reads every path alias', () => {
+ expect(extractToolArgPath({ path: '/a' })).toBe('/a');
+ expect(extractToolArgPath({ file_path: '/b' })).toBe('/b');
+ expect(extractToolArgPath({ filePath: '/c' })).toBe('/c');
+ });
+
+ it('returns undefined when no path alias is a usable string', () => {
+ expect(extractToolArgPath({ content: 'x' })).toBeUndefined();
+ expect(extractToolArgPath({ path: '' })).toBeUndefined();
+ expect(extractToolArgPath({ path: 7 })).toBeUndefined();
+ expect(extractToolArgPath(null)).toBeUndefined();
+ expect(extractToolArgPath(undefined)).toBeUndefined();
+ });
+});
+
describe('parseWriteFileMeta', () => {
it('returns null for sections with a different tool name', () => {
expect(
@@ -176,12 +196,23 @@ describe('parseWriteFileMeta', () => {
).toBeNull();
});
- it('returns null when args have no path-like field', () => {
- expect(
- parseWriteFileMeta(
- makeSection({ toolArgs: '{"content":"x"}', toolName: BuiltInTool.SERVER_WRITE_FILE })
- )
- ).toBeNull();
+ it('returns content without a path when args have no path-like field', () => {
+ const meta = parseWriteFileMeta(
+ makeSection({ toolArgs: '{"content":"x"}', toolName: BuiltInTool.SERVER_WRITE_FILE })
+ );
+
+ expect(meta?.content).toBe('x');
+ expect(meta?.filePath).toBeUndefined();
+ expect(meta?.fileName).toBeUndefined();
+ expect(meta?.language).toBe(CODE_BLOCK.DEFAULT_LANGUAGE);
+ });
+
+ it('returns content streamed before the path has arrived', () => {
+ const meta = parseWriteFileMeta(
+ makeSection({ toolArgs: '{"content":"hello ', toolName: BuiltInTool.SERVER_WRITE_FILE })
+ );
+
+ expect(meta?.content).toBe('hello ');
});
it('accepts partial args (renders incrementally as content streams in)', () => {
@@ -279,6 +310,16 @@ describe('parseWriteFileTitleMeta', () => {
expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/foo.t');
});
+ it('returns meta without a path when args have no path-like field', () => {
+ const title = parseWriteFileTitleMeta(
+ makeSection({ toolArgs: '{"content":"x"}', toolName: BuiltInTool.SERVER_WRITE_FILE })
+ );
+
+ expect(title?.filePath).toBeUndefined();
+ expect(title?.fileName).toBeUndefined();
+ expect(title?.language).toBe(CODE_BLOCK.DEFAULT_LANGUAGE);
+ });
+
it('returns null for sections with a different tool name', () => {
expect(
parseWriteFileTitleMeta(
@@ -324,12 +365,13 @@ describe('parseEditFileTitleMeta', () => {
expect(parseEditFileTitleMeta(section)?.errorMessage).toBe('permission denied');
});
- it('returns null when args have no path-like field', () => {
- expect(
- parseEditFileTitleMeta(
- makeSection({ toolArgs: '{"edits":[]}', toolName: BuiltInTool.SERVER_EDIT_FILE })
- )
- ).toBeNull();
+ it('returns meta without a path when args have no path-like field', () => {
+ const title = parseEditFileTitleMeta(
+ makeSection({ toolArgs: '{"edits":[]}', toolName: BuiltInTool.SERVER_EDIT_FILE })
+ );
+
+ expect(title?.filePath).toBeUndefined();
+ expect(title?.fileName).toBeUndefined();
});
});
@@ -383,6 +425,33 @@ describe('parseEditFileMeta', () => {
expect(meta?.errorMessage).toBe('bad path');
expect(meta?.resultMessage).toBeUndefined();
});
+
+ it('still parses edits when args have no path-like field', () => {
+ const meta = parseEditFileMeta(
+ makeSection({
+ toolArgs: '{"edits":[{"old_text":"a","new_text":"b"}]}',
+ toolName: BuiltInTool.SERVER_EDIT_FILE
+ })
+ );
+
+ expect(meta?.edits).toEqual([{ newText: 'b', oldText: 'a' }]);
+ expect(meta?.filePath).toBeUndefined();
+ });
+
+ it('parses edits streamed before the path has arrived', () => {
+ const meta = parseEditFileMeta(
+ makeSection({
+ // second edit is still mid-stream: only its old_text has landed
+ toolArgs: '{"edits":[{"old_text":"a","new_text":"b"},{"old_text":"c',
+ toolName: BuiltInTool.SERVER_EDIT_FILE
+ })
+ );
+
+ expect(meta?.edits).toEqual([
+ { newText: 'b', oldText: 'a' },
+ { newText: '', oldText: 'c' }
+ ]);
+ });
});
describe('parseReadFileMeta', () => {
@@ -647,4 +716,37 @@ describe('parseExecShellCommandMeta', () => {
)
).toBeNull();
});
+
+ it('surfaces a partially streamed command before the JSON closes', () => {
+ const meta = parseExecShellCommandMeta(
+ makeSection(
+ { toolArgs: '{"command":"git sta', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
+ BuiltInTool.SERVER_EXEC_SHELL_COMMAND
+ )
+ );
+
+ expect(meta?.command).toBe('git sta');
+ });
+
+ it('keeps a dangling escape from breaking the partial parse', () => {
+ const meta = parseExecShellCommandMeta(
+ makeSection(
+ { toolArgs: '{"command":"echo \\"he', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
+ BuiltInTool.SERVER_EXEC_SHELL_COMMAND
+ )
+ );
+
+ expect(meta?.command).toBe('echo "he');
+ });
+
+ it('returns null while the command value is still empty', () => {
+ expect(
+ parseExecShellCommandMeta(
+ makeSection(
+ { toolArgs: '{"command":"', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
+ BuiltInTool.SERVER_EXEC_SHELL_COMMAND
+ )
+ )
+ ).toBeNull();
+ });
});