]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/blob
639bee89784a55f8503f48d996982144076d1520
[pkg/ggml/sources/llama.cpp] /
1 <script lang="ts">
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
7 // before extraction.
8
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 { settingsStore, toolsStore } from '$lib/stores';
16 import type { AgenticSection, ToolResultLine } from '$lib/types';
17 import type { DatabaseMessageExtra } from '$lib/types';
18 import {
19 abbreviateHome,
20 type ExecShellExitStatus,
21 highlightCode,
22 isExitCodeSummaryLine,
23 parseExecShellCommandError,
24 parseExecShellCommandExitStatus,
25 parseToolResultWithMedia
26 } from '$lib/utils';
27
28 interface Props {
29 section: AgenticSection;
30 open: boolean;
31 isStreaming: boolean;
32 /** True while the agentic loop is streaming output chunks for THIS
33 * tool call. Drives max-height + auto-scroll while true; releases
34 * them when the loop reports this call as done. */
35 isExecuting?: boolean;
36 attachments?: DatabaseMessageExtra[];
37 onToggle?: () => void;
38 }
39
40 let { attachments, isExecuting = false, isStreaming, onToggle, open, section }: Props = $props();
41
42 // `isLive` covers all in-flight phases: pre-chunk spinner and
43 // streaming itself. Frozen output (tool done while agent continues)
44 // is not live.
45 const isLive = $derived(isExecuting);
46
47 const execShellMeta = $derived(parseExecShellCommandMeta(section));
48 const execShellError = $derived(parseExecShellCommandError(section.toolResult));
49 const execShellExitStatus: ExecShellExitStatus | undefined = $derived(
50 parseExecShellCommandExitStatus(section.toolResult)
51 );
52
53 const parsedLines: ToolResultLine[] = $derived(
54 section.toolResult ? parseToolResultWithMedia(section.toolResult, attachments) : []
55 );
56
57 // Drop the trailing "[exit code: N]" line - rendered as a colored
58 // badge below. During streaming we keep it so a partial stream still
59 // shows the status once the final chunk lands.
60 const outputLines: ToolResultLine[] = $derived(
61 execShellExitStatus && parsedLines.length > 0
62 ? parsedLines.slice(0, parsedLines.length - 1)
63 : parsedLines
64 );
65
66 const isExitCodeFinalLine = $derived(
67 execShellExitStatus !== undefined &&
68 parsedLines.length > 0 &&
69 isExitCodeSummaryLine(parsedLines[parsedLines.length - 1].text, execShellExitStatus)
70 );
71
72 // Highlight just the command for the title; the (typically large)
73 // output blob uses bare monospace to skip hljs per-line highlighting.
74 const highlightedCommandHtml = $derived(
75 execShellMeta ? highlightCode(execShellMeta.command, 'bash') : ''
76 );
77
78 // The working directory the command ran with, persisted per call on the
79 // tool result message (it travels via the x-tool-cwd header, not the tool
80 // args). Reading it from the section keeps it accurate even if the
81 // conversation cwd changes later.
82 const cwd = $derived(section.toolCwd);
83 const home = $derived(toolsStore.serverHome);
84 const wdDisplay = $derived(abbreviateHome(cwd ?? '', home));
85
86 const exitBadgeClass = $derived(
87 execShellExitStatus?.timedOut
88 ? 'exit-badge warning'
89 : execShellExitStatus?.code === 0
90 ? 'exit-badge success'
91 : 'exit-badge failure'
92 );
93
94 const useFullHeightCodeBlocks = $derived(
95 Boolean(settingsStore.config[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS])
96 );
97
98 const autoScroll = $derived(isLive && !useFullHeightCodeBlocks);
99
100 const SCROLL_BOTTOM_THRESHOLD_PX = TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX;
101
102 let scrollEl: HTMLDivElement | undefined = $state();
103 let userScrolledUp = $state(false);
104 let lastScrollTop = 0;
105 let pendingFrame: number | null = null;
106
107 function isAtBottom(): boolean {
108 if (!scrollEl) return false;
109
110 return (
111 scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
112 SCROLL_BOTTOM_THRESHOLD_PX
113 );
114 }
115
116 function scrollToBottomOnFrame() {
117 if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
118
119 pendingFrame = requestAnimationFrame(() => {
120 pendingFrame = null;
121
122 // Re-check on rAF - user may scroll between scheduling and paint.
123 if (scrollEl && !userScrolledUp) {
124 scrollEl.scrollTop = scrollEl.scrollHeight;
125 }
126 });
127 }
128
129 function handleScrollEvent() {
130 if (!scrollEl) return;
131
132 const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
133
134 if (isScrollingUp && !isAtBottom()) {
135 userScrolledUp = true;
136 } else if (isAtBottom()) {
137 userScrolledUp = false;
138 }
139
140 lastScrollTop = scrollEl.scrollTop;
141 }
142
143 $effect(() => {
144 void section.toolResult;
145
146 if (!scrollEl || !autoScroll) return;
147
148 scrollToBottomOnFrame();
149 });
150
151 $effect(() => {
152 // Catch layout changes that don't touch toolResult (line-wrap
153 // reflow, image attaches, hljs settle).
154 if (!scrollEl || !autoScroll) return;
155
156 const observer = new MutationObserver(() => scrollToBottomOnFrame());
157
158 observer.observe(scrollEl, {
159 characterData: true,
160 childList: true,
161 subtree: true
162 });
163
164 return () => observer.disconnect();
165 });
166
167 $effect(() => {
168 // Reset on stream end so the next render (full-height) starts
169 // pinned.
170 if (!isLive) {
171 userScrolledUp = false;
172 lastScrollTop = 0;
173 }
174 });
175 </script>
176
177 {#snippet execShellTitle()}
178 {#if cwd}
179 <span class="exec-wd" title={cwd}>{wdDisplay}</span>
180 <span class="exec-prompt">$</span>
181 {/if}
182
183 {#if highlightedCommandHtml}
184 <span class="font-mono">{@html highlightedCommandHtml}</span>
185 {:else}
186 <span class="font-mono">{execShellMeta?.command}</span>
187 {/if}
188 {/snippet}
189
190 <ToolCallBlock
191 {section}
192 {open}
193 {isStreaming}
194 meta={execShellMeta ? { errorMessage: execShellError } : null}
195 wrapper={CollapsibleTerminalBlock}
196 extraLiveStreaming={isLive}
197 spinIconWhenActive={true}
198 {onToggle}
199 >
200 {#snippet titleSnippet()}
201 {@render execShellTitle()}
202 {/snippet}
203
204 {#snippet children(_meta, ctx)}
205 {#if ctx.isPending}
206 <div class="flex items-start gap-2 text-xs text-muted-foreground/70">
207 <Loader2 class="h-3 w-3 animate-spin" />
208 Running...
209 </div>
210 {:else if execShellError}
211 <div class="flex items-start gap-2 text-xs text-red-600 italic dark:text-red-400">
212 <XCircle class="mt-0.5 h-3 w-3 shrink-0" />
213 <span>{execShellError}</span>
214 </div>
215 {:else if section.toolResult}
216 <div
217 bind:this={scrollEl}
218 class="terminal-output"
219 class:is-clamped={!useFullHeightCodeBlocks}
220 onscroll={handleScrollEvent}
221 >
222 {#each outputLines as line, i (i)}
223 <div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{line.text}</div>
224 {#if line.media?.type === AttachmentType.IMAGE}
225 <img
226 src={line.media.base64Url}
227 alt={line.media.name}
228 class="mt-2 mb-2 h-auto max-w-full rounded-lg"
229 loading="lazy"
230 />
231 {/if}
232 {/each}
233
234 {#if isExitCodeFinalLine && execShellExitStatus}
235 <div class={exitBadgeClass}>
236 {#if execShellExitStatus.timedOut}
237 <AlertTriangle class="h-3 w-3" />
238 <span>timed out</span>
239 <span class="exit-sep">&middot;</span>
240 <span>exit {execShellExitStatus.code}</span>
241 {:else if execShellExitStatus.code === 0}
242 <Check class="h-3 w-3" />
243 <span>exit 0</span>
244 {:else}
245 <XCircle class="h-3 w-3" />
246 <span>exit {execShellExitStatus.code}</span>
247 {/if}
248 </div>
249 {/if}
250 </div>
251 {/if}
252 {/snippet}
253 </ToolCallBlock>
254
255 <style>
256 :root {
257 --exec-wd-margin: 0.4rem;
258 }
259
260 .exec-wd {
261 font-family: var(--font-mono);
262 color: var(--muted-foreground);
263 margin-right: var(--exec-wd-margin);
264 }
265
266 .exec-prompt {
267 font-family: var(--font-mono);
268 color: var(--muted-foreground);
269 opacity: 0.55;
270 margin-right: var(--exec-wd-margin);
271 }
272
273 .terminal-output {
274 overscroll-behavior: contain;
275 }
276
277 .terminal-output.is-clamped {
278 max-height: 28rem;
279 overflow-y: auto;
280 scrollbar-gutter: stable;
281 padding-right: 0.25rem;
282 }
283
284 .exit-badge {
285 display: inline-flex;
286 align-items: center;
287 gap: 0.35rem;
288 margin-top: 0.5rem;
289 padding: 0.2rem 0.55rem;
290 border-radius: 0.375rem;
291 font-family: var(--font-mono);
292 font-size: 11px;
293 font-weight: 500;
294 letter-spacing: 0.01em;
295 line-height: 1;
296 }
297
298 .exit-badge.success {
299 background: color-mix(in oklch, var(--color-green-500, #22c55e) 14%, transparent);
300 color: var(--color-green-700, #15803d);
301 }
302
303 :global(.dark) .exit-badge.success {
304 background: color-mix(in oklch, var(--color-green-400, #4ade80) 18%, transparent);
305 color: var(--color-green-300, #86efac);
306 }
307
308 .exit-badge.failure {
309 background: color-mix(in oklch, var(--color-red-500, #ef4444) 14%, transparent);
310 color: var(--color-red-700, #b91c1c);
311 }
312
313 :global(.dark) .exit-badge.failure {
314 background: color-mix(in oklch, var(--color-red-400, #f87171) 18%, transparent);
315 color: var(--color-red-300, #fca5a5);
316 }
317
318 .exit-badge.warning {
319 background: color-mix(in oklch, var(--color-amber-500, #f59e0b) 14%, transparent);
320 color: var(--color-amber-700, #b45309);
321 }
322
323 :global(.dark) .exit-badge.warning {
324 background: color-mix(in oklch, var(--color-amber-400, #fbbf24) 18%, transparent);
325 color: var(--color-amber-300, #fcd34d);
326 }
327
328 .exit-sep {
329 opacity: 0.45;
330 }
331 </style>