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