Commit 64be95b16 for llama.cpp

commit 64be95b163fe3a80327fad1ab84dc162302cf235
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date:   Mon Nov 2 12:34:00 2026 +0100

    ui: show interrupted state for tool calls that never returned (#31314)

    * ui: show interrupted state for tool calls that never returned

    The chat-level streaming flag was applied to every assistant message, so a
    tool call left without a result in an earlier message kept spinning while a
    newer reply streamed. Scope streaming to the last assistant message and mark
    finished calls without a result as interrupted.

    Assisted-by: Claude (Opus 5.5 Medium)

    * ui: mark a shell command stopped before any output as interrupted

    exec_shell_command creates its result message before running, so stopping
    it mid-run leaves an empty result instead of none.

    Assisted-by: Claude (Opus 5.5 Medium)

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 665238a29..a67de857c 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
@@ -8,7 +8,7 @@

 	import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
 	import ToolCallBlock from './ToolCallBlock.svelte';
-	import { AlertTriangle, Check, Loader2, XCircle } from '@lucide/svelte';
+	import { AlertTriangle, Check, CircleStop, 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 { AgenticSectionType, AttachmentType } from '$lib/enums';
@@ -229,6 +229,7 @@
 {/snippet}

 <ToolCallBlock
+	emptyResultIsInterrupted
 	extraLiveStreaming={isLive}
 	{isStreaming}
 	meta={execShellMeta ? { errorMessage: execShellError } : null}
@@ -248,6 +249,12 @@
 				<Loader2 class="h-3 w-3 animate-spin" />
 				Executing command...
 			</div>
+		{:else if ctx.isInterrupted}
+			<div class="flex items-start gap-2 text-xs text-muted-foreground/70 italic">
+				<CircleStop class="mt-0.5 h-3 w-3 shrink-0" />
+
+				<span>Command interrupted before it returned output</span>
+			</div>
 		{:else if execShellError}
 			<div class="flex items-start gap-2 text-xs text-red-600 italic dark:text-red-400">
 				<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte
index 16dd6e108..461ffe6f7 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ToolCallBlock.svelte
@@ -25,6 +25,7 @@
 		isPending: boolean;
 		isStreamingCall: boolean;
 		isCodeStreaming: boolean;
+		isInterrupted: boolean;
 	}

 	interface Props {
@@ -50,6 +51,12 @@
 		 * inline within the body.
 		 */
 		spinIconWhenActive?: boolean;
+		/**
+		 * Treat an empty result as interrupted too. For tools that create
+		 * their result message before running (`exec_shell_command`), so a
+		 * stop mid-run leaves an empty result rather than none.
+		 */
+		emptyResultIsInterrupted?: boolean;
 		/**
 		 * Wrapper component that renders the title row and the body
 		 * children. Defaults to CollapsibleContentBlock;
@@ -65,6 +72,7 @@

 	let {
 		children,
+		emptyResultIsInterrupted = false,
 		extraLiveStreaming = false,
 		isStreaming,
 		meta,
@@ -81,6 +89,13 @@
 	const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
 	const showSpinner = $derived(isPending || (isStreamingCall && isStreaming) || extraLiveStreaming);
 	const isCodeStreaming = $derived(isStreaming && (isPending || isStreamingCall));
+	// A finished call with no result was stopped before it returned (user
+	// abort, or the page went away mid-execution).
+	const isInterrupted = $derived(
+		section.type === AgenticSectionType.TOOL_CALL &&
+			(section.toolResult === undefined || (emptyResultIsInterrupted && !section.toolResult)) &&
+			!showSpinner
+	);

 	const toolUi: ToolUiEntry | null = $derived(getToolUi(section.toolName));
 	const toolIcon: Component = $derived(
@@ -107,6 +122,8 @@

 		if (isStreamingCall && !isStreaming) return 'incomplete';

+		if (isInterrupted) return 'interrupted';
+
 		return undefined;
 	}

@@ -126,6 +143,7 @@
 >
 	{@render children(meta, {
 		isCodeStreaming,
+		isInterrupted,
 		isPending,
 		isStreaming,
 		isStreamingCall
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 b6138dfc0..c2a8188ec 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte
@@ -26,11 +26,16 @@

 	let {
 		isLastAssistantMessage = false,
-		isStreaming = false,
+		isStreaming: isChatStreaming = false,
 		message,
 		toolMessages = []
 	}: Props = $props();

+	// The chat-level streaming flag is shared by every message; only the last
+	// assistant message is actually live, so earlier ones must not keep their
+	// unresolved tool calls spinning while a newer reply streams.
+	const isStreaming = $derived(isChatStreaming && isLastAssistantMessage);
+
 	let expandedStates: Record<number, boolean> = $state({});

 	const showThoughtInProgress = $derived(Boolean(settingsStore.config.showThoughtInProgress));
@@ -48,9 +53,7 @@
 	);

 	const pendingPermission = $derived(
-		isStreaming && isLastAssistantMessage
-			? agenticStore.getPendingPermissionRequest(message.convId)
-			: null
+		isStreaming ? agenticStore.getPendingPermissionRequest(message.convId) : null
 	);

 	// dismissal applies to the request object, so the next request ( new
@@ -67,9 +70,7 @@
 	}

 	const pendingContinue = $derived(
-		isStreaming && isLastAssistantMessage
-			? agenticStore.getPendingContinueRequest(message.convId)
-			: false
+		isStreaming ? agenticStore.getPendingContinueRequest(message.convId) : false
 	);

 	let continueDismissed = $state(false);
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
index 87668c0e3..e948cc922 100644
--- a/tools/ui/tests/client/exec-shell-command-streaming.svelte.test.ts
+++ b/tools/ui/tests/client/exec-shell-command-streaming.svelte.test.ts
@@ -81,6 +81,7 @@ describe('exec_shell_command streaming title', () => {

 	it('streams the partial command into the title with a cursor', async () => {
 		const { container } = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: messageWithArgs('{"command":"git sta'),
 			toolMessages: []
@@ -98,6 +99,7 @@ describe('exec_shell_command streaming title', () => {

 	it('drops the cursor once the args JSON is complete', async () => {
 		const screen = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: messageWithArgs('{"command":"git sta'),
 			toolMessages: []
@@ -108,6 +110,7 @@ describe('exec_shell_command streaming title', () => {
 		expect(hasCursor(screen.container)).toBe(true);

 		screen.rerender({
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: messageWithArgs('{"command":"git status"}'),
 			toolMessages: []
@@ -120,6 +123,7 @@ describe('exec_shell_command streaming title', () => {

 	it('switches the prompt from the conversation cwd to the persisted one', async () => {
 		const screen = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: messageWithArgs('{"command":"git status"}'),
 			toolMessages: []
@@ -131,6 +135,7 @@ describe('exec_shell_command streaming title', () => {

 		// Result message lands: its x-tool-cwd takes over the prompt.
 		screen.rerender({
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: messageWithArgs('{"command":"git status"}'),
 			toolMessages: [toolResultMessage('', '/other/dir')]
@@ -143,6 +148,7 @@ describe('exec_shell_command streaming title', () => {

 	it('shows the executing label only after the command finishes streaming', async () => {
 		const screen = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: messageWithArgs('{"command":"git sta'),
 			toolMessages: []
@@ -154,6 +160,7 @@ describe('exec_shell_command streaming title', () => {
 		expect(screen.container.textContent).not.toContain('Executing command...');

 		screen.rerender({
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: messageWithArgs('{"command":"git status"}'),
 			toolMessages: []
@@ -192,6 +199,7 @@ describe('exec_shell_command streaming title', () => {
 		conversationsStore.activeMessages = [messageWithArgs('')];

 		const screen = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: conversationsStore.activeMessages[0]!,
 			toolMessages: []
@@ -214,3 +222,57 @@ describe('exec_shell_command streaming title', () => {
 		}
 	});
 });
+
+describe('exec_shell_command interrupted call', () => {
+	beforeEach(() => {
+		settingsStore.updateMultipleConfig({
+			[SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT]: true,
+			[SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS]: true
+		});
+	});
+
+	it('marks a call without a result as interrupted once streaming stops', async () => {
+		const { container } = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: true,
+			isStreaming: false,
+			message: messageWithArgs('{"command":"git status"}'),
+			toolMessages: []
+		});
+
+		await tick();
+
+		expect(triggerText(container)).toContain('interrupted');
+		expect(container.textContent).toContain('Command interrupted');
+		expect(container.textContent).not.toContain('Executing command...');
+	});
+
+	it('marks a command stopped before any output as interrupted', async () => {
+		// exec_shell_command creates its result message before running, so a
+		// stop mid-run leaves an empty result rather than none.
+		const { container } = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: true,
+			isStreaming: false,
+			message: messageWithArgs('{"command":"sleep 40"}'),
+			toolMessages: [toolResultMessage('', CWD)]
+		});
+
+		await tick();
+
+		expect(triggerText(container)).toContain('interrupted');
+		expect(container.textContent).toContain('Command interrupted');
+	});
+
+	it('does not keep spinning in an earlier message while a newer reply streams', async () => {
+		const { container } = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: false,
+			isStreaming: true,
+			message: messageWithArgs('{"command":"git status"}'),
+			toolMessages: []
+		});
+
+		await tick();
+
+		expect(triggerText(container)).toContain('interrupted');
+		expect(container.textContent).not.toContain('Executing command...');
+	});
+});
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
index e51fcb795..ffb28e369 100644
--- a/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts
+++ b/tools/ui/tests/client/tool-call-in-progress.svelte.test.ts
@@ -95,6 +95,7 @@ describe('Show tool call in progress', () => {
 		setSettings({ alwaysShow: false, inProgress: true });

 		const { container } = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: assistantMessage(),
 			toolMessages: []
@@ -107,6 +108,7 @@ describe('Show tool call in progress', () => {

 	it('keeps the call collapsed while executing when the setting is off', async () => {
 		const { container } = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: assistantMessage(),
 			toolMessages: []
@@ -123,6 +125,7 @@ describe('Show tool call in progress', () => {
 		setSettings({ alwaysShow: false, inProgress: true });

 		const screen = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: assistantMessage(),
 			toolMessages: []
@@ -135,6 +138,7 @@ describe('Show tool call in progress', () => {

 		// Same block, now finished: it must close on its own, no toggle given.
 		screen.rerender({
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: assistantMessage(),
 			toolMessages: [toolResultMessage()]
@@ -150,6 +154,7 @@ describe('Show tool call in progress', () => {
 		setSettings({ alwaysShow: false, inProgress: true });

 		const screen = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: assistantMessage(),
 			toolMessages: []
@@ -161,6 +166,7 @@ describe('Show tool call in progress', () => {
 		await collapseBlock(screen);

 		screen.rerender({
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: assistantMessage(),
 			toolMessages: [toolResultMessage()]
@@ -175,6 +181,7 @@ describe('Show tool call in progress', () => {
 		setSettings({ alwaysShow: true, inProgress: false });

 		const { container } = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: assistantMessage(),
 			toolMessages: [toolResultMessage()]
@@ -189,6 +196,7 @@ describe('Show tool call in progress', () => {
 		setSettings({ alwaysShow: false, inProgress: true });

 		const screen = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: assistantMessage(),
 			toolMessages: []
@@ -206,6 +214,7 @@ describe('Show tool call in progress', () => {

 	it('lets a manual expand win over the collapsed default', async () => {
 		const screen = render(ChatMessageAgenticContent, {
+			isLastAssistantMessage: true,
 			isStreaming: true,
 			message: assistantMessage(),
 			toolMessages: []