2 // Block for `exec_shell_command`. Unlike the other tools, this
3 // renderer uses CollapsibleTerminalBlock (terminal-style frame)
4 // and treats "live" output chunks as active even after the call
5 // resolved, so the spinner stays on while stdout is still flowing.
6 // The scroll-to-bottom auto-scroll logic mirrors what was here
9 import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
10 import ToolCallBlock from './ToolCallBlock.svelte';
11 import { AlertTriangle, Check, Loader2, XCircle } from '@lucide/svelte';
12 import { CollapsibleTerminalBlock } from '$lib/components/app';
13 import { SETTINGS_KEYS, TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants';
14 import { AttachmentType } from '$lib/enums';
15 import { config } from '$lib/stores/settings.svelte';
16 import { toolsStore } from '$lib/stores/tools.svelte';
17 import type { AgenticSection, ToolResultLine } from '$lib/types';
18 import type { DatabaseMessageExtra } from '$lib/types';
21 type ExecShellExitStatus,
23 isExitCodeSummaryLine,
24 parseExecShellCommandError,
25 parseExecShellCommandExitStatus,
26 parseToolResultWithMedia
30 section: AgenticSection;
33 /** True while the agentic loop is streaming output chunks for THIS
34 * tool call. Drives max-height + auto-scroll while true; releases
35 * them when the loop reports this call as done. */
36 isExecuting?: boolean;
37 attachments?: DatabaseMessageExtra[];
38 onToggle?: () => void;
41 let { attachments, isExecuting = false, isStreaming, onToggle, open, section }: Props = $props();
43 // `isLive` covers all in-flight phases: pre-chunk spinner and
44 // streaming itself. Frozen output (tool done while agent continues)
46 const isLive = $derived(isExecuting);
48 const execShellMeta = $derived(parseExecShellCommandMeta(section));
49 const execShellError = $derived(parseExecShellCommandError(section.toolResult));
50 const execShellExitStatus: ExecShellExitStatus | undefined = $derived(
51 parseExecShellCommandExitStatus(section.toolResult)
54 const parsedLines: ToolResultLine[] = $derived(
55 section.toolResult ? parseToolResultWithMedia(section.toolResult, attachments) : []
58 // Drop the trailing "[exit code: N]" line - rendered as a colored
59 // badge below. During streaming we keep it so a partial stream still
60 // shows the status once the final chunk lands.
61 const outputLines: ToolResultLine[] = $derived(
62 execShellExitStatus && parsedLines.length > 0
63 ? parsedLines.slice(0, parsedLines.length - 1)
67 const isExitCodeFinalLine = $derived(
68 execShellExitStatus !== undefined &&
69 parsedLines.length > 0 &&
70 isExitCodeSummaryLine(parsedLines[parsedLines.length - 1].text, execShellExitStatus)
73 // Highlight just the command for the title; the (typically large)
74 // output blob uses bare monospace to skip hljs per-line highlighting.
75 const highlightedCommandHtml = $derived(
76 execShellMeta ? highlightCode(execShellMeta.command, 'bash') : ''
79 // The working directory the command ran with, persisted per call on the
80 // tool result message (it travels via the x-tool-cwd header, not the tool
81 // args). Reading it from the section keeps it accurate even if the
82 // conversation cwd changes later.
83 const cwd = $derived(section.toolCwd);
84 const home = $derived(toolsStore.serverHome);
85 const wdDisplay = $derived(abbreviateHome(cwd ?? '', home));
87 const exitBadgeClass = $derived(
88 execShellExitStatus?.timedOut
89 ? 'exit-badge warning'
90 : execShellExitStatus?.code === 0
91 ? 'exit-badge success'
92 : 'exit-badge failure'
95 const useFullHeightCodeBlocks = $derived(
96 Boolean(config()[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS])
99 const autoScroll = $derived(isLive && !useFullHeightCodeBlocks);
101 const SCROLL_BOTTOM_THRESHOLD_PX = TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX;
103 let scrollEl: HTMLDivElement | undefined = $state();
104 let userScrolledUp = $state(false);
105 let lastScrollTop = 0;
106 let pendingFrame: number | null = null;
108 function isAtBottom(): boolean {
109 if (!scrollEl) return false;
112 scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
113 SCROLL_BOTTOM_THRESHOLD_PX
117 function scrollToBottomOnFrame() {
118 if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
120 pendingFrame = requestAnimationFrame(() => {
123 // Re-check on rAF - user may scroll between scheduling and paint.
124 if (scrollEl && !userScrolledUp) {
125 scrollEl.scrollTop = scrollEl.scrollHeight;
130 function handleScrollEvent() {
131 if (!scrollEl) return;
133 const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
135 if (isScrollingUp && !isAtBottom()) {
136 userScrolledUp = true;
137 } else if (isAtBottom()) {
138 userScrolledUp = false;
141 lastScrollTop = scrollEl.scrollTop;
145 void section.toolResult;
147 if (!scrollEl || !autoScroll) return;
149 scrollToBottomOnFrame();
153 // Catch layout changes that don't touch toolResult (line-wrap
154 // reflow, image attaches, hljs settle).
155 if (!scrollEl || !autoScroll) return;
157 const observer = new MutationObserver(() => scrollToBottomOnFrame());
159 observer.observe(scrollEl, {
165 return () => observer.disconnect();
169 // Reset on stream end so the next render (full-height) starts
172 userScrolledUp = false;
178 {#snippet execShellTitle()}
180 <span class="exec-wd" title={cwd}>{wdDisplay}</span>
181 <span class="exec-prompt">$</span>
184 {#if highlightedCommandHtml}
185 <span class="font-mono">{@html highlightedCommandHtml}</span>
187 <span class="font-mono">{execShellMeta?.command}</span>
195 meta={execShellMeta ? { errorMessage: execShellError } : null}
196 wrapper={CollapsibleTerminalBlock}
197 extraLiveStreaming={isLive}
198 spinIconWhenActive={true}
201 {#snippet titleSnippet()}
202 {@render execShellTitle()}
205 {#snippet children(_meta, ctx)}
207 <div class="flex items-start gap-2 text-xs text-muted-foreground/70">
208 <Loader2 class="h-3 w-3 animate-spin" />
211 {:else if execShellError}
212 <div class="flex items-start gap-2 text-xs text-red-600 italic dark:text-red-400">
213 <XCircle class="mt-0.5 h-3 w-3 shrink-0" />
214 <span>{execShellError}</span>
216 {:else if section.toolResult}
219 class="terminal-output"
220 class:is-clamped={!useFullHeightCodeBlocks}
221 onscroll={handleScrollEvent}
223 {#each outputLines as line, i (i)}
224 <div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{line.text}</div>
225 {#if line.media?.type === AttachmentType.IMAGE}
227 src={line.media.base64Url}
228 alt={line.media.name}
229 class="mt-2 mb-2 h-auto max-w-full rounded-lg"
235 {#if isExitCodeFinalLine && execShellExitStatus}
236 <div class={exitBadgeClass}>
237 {#if execShellExitStatus.timedOut}
238 <AlertTriangle class="h-3 w-3" />
239 <span>timed out</span>
240 <span class="exit-sep">·</span>
241 <span>exit {execShellExitStatus.code}</span>
242 {:else if execShellExitStatus.code === 0}
243 <Check class="h-3 w-3" />
246 <XCircle class="h-3 w-3" />
247 <span>exit {execShellExitStatus.code}</span>
258 --exec-wd-margin: 0.4rem;
262 font-family: var(--font-mono);
263 color: var(--muted-foreground);
264 margin-right: var(--exec-wd-margin);
268 font-family: var(--font-mono);
269 color: var(--muted-foreground);
271 margin-right: var(--exec-wd-margin);
275 overscroll-behavior: contain;
278 .terminal-output.is-clamped {
281 scrollbar-gutter: stable;
282 padding-right: 0.25rem;
286 display: inline-flex;
290 padding: 0.2rem 0.55rem;
291 border-radius: 0.375rem;
292 font-family: var(--font-mono);
295 letter-spacing: 0.01em;
299 .exit-badge.success {
300 background: color-mix(in oklch, var(--color-green-500, #22c55e) 14%, transparent);
301 color: var(--color-green-700, #15803d);
304 :global(.dark) .exit-badge.success {
305 background: color-mix(in oklch, var(--color-green-400, #4ade80) 18%, transparent);
306 color: var(--color-green-300, #86efac);
309 .exit-badge.failure {
310 background: color-mix(in oklch, var(--color-red-500, #ef4444) 14%, transparent);
311 color: var(--color-red-700, #b91c1c);
314 :global(.dark) .exit-badge.failure {
315 background: color-mix(in oklch, var(--color-red-400, #f87171) 18%, transparent);
316 color: var(--color-red-300, #fca5a5);
319 .exit-badge.warning {
320 background: color-mix(in oklch, var(--color-amber-500, #f59e0b) 14%, transparent);
321 color: var(--color-amber-700, #b45309);
324 :global(.dark) .exit-badge.warning {
325 background: color-mix(in oklch, var(--color-amber-400, #fbbf24) 18%, transparent);
326 color: var(--color-amber-300, #fcd34d);