]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/blob
5de801d39a488d987e7a9189667494dbd9db0541
[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 { Check, Loader2, XCircle, AlertTriangle } from '@lucide/svelte';
10 import { CollapsibleTerminalBlock } from '$lib/components/app';
11 import { SETTINGS_KEYS } from '$lib/constants';
12 import { config } from '$lib/stores/settings.svelte';
13 import { TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
14 import {
15 highlightCode,
16 isExitCodeSummaryLine,
17 parseExecShellCommandError,
18 parseExecShellCommandExitStatus,
19 parseToolResultWithImages,
20 type AgenticSection,
21 type ExecShellExitStatus,
22 type ToolResultLine
23 } from '$lib/utils';
24 import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
25 import type { DatabaseMessageExtra } from '$lib/types';
26 import ToolCallBlock from './ToolCallBlock.svelte';
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 { section, open, isStreaming, isExecuting = false, attachments, onToggle }: 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 ? parseToolResultWithImages(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 const exitBadgeClass = $derived(
79 execShellExitStatus?.timedOut
80 ? 'exit-badge warning'
81 : execShellExitStatus?.code === 0
82 ? 'exit-badge success'
83 : 'exit-badge failure'
84 );
85
86 const useFullHeightCodeBlocks = $derived(
87 Boolean(config()[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS])
88 );
89
90 const autoScroll = $derived(isLive && !useFullHeightCodeBlocks);
91
92 const SCROLL_BOTTOM_THRESHOLD_PX = TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX;
93
94 let scrollEl: HTMLDivElement | undefined = $state();
95 let userScrolledUp = $state(false);
96 let lastScrollTop = 0;
97 let pendingFrame: number | null = null;
98
99 function isAtBottom(): boolean {
100 if (!scrollEl) return false;
101 return (
102 scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
103 SCROLL_BOTTOM_THRESHOLD_PX
104 );
105 }
106
107 function scrollToBottomOnFrame() {
108 if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
109 pendingFrame = requestAnimationFrame(() => {
110 pendingFrame = null;
111
112 // Re-check on rAF - user may scroll between scheduling and paint.
113 if (scrollEl && !userScrolledUp) {
114 scrollEl.scrollTop = scrollEl.scrollHeight;
115 }
116 });
117 }
118
119 function handleScrollEvent() {
120 if (!scrollEl) return;
121 const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
122 if (isScrollingUp && !isAtBottom()) {
123 userScrolledUp = true;
124 } else if (isAtBottom()) {
125 userScrolledUp = false;
126 }
127 lastScrollTop = scrollEl.scrollTop;
128 }
129
130 $effect(() => {
131 void section.toolResult;
132 if (!scrollEl || !autoScroll) return;
133 scrollToBottomOnFrame();
134 });
135
136 $effect(() => {
137 // Catch layout changes that don't touch toolResult (line-wrap
138 // reflow, image attaches, hljs settle).
139 if (!scrollEl || !autoScroll) return;
140
141 const observer = new MutationObserver(() => scrollToBottomOnFrame());
142 observer.observe(scrollEl, {
143 childList: true,
144 subtree: true,
145 characterData: true
146 });
147
148 return () => observer.disconnect();
149 });
150
151 $effect(() => {
152 // Reset on stream end so the next render (full-height) starts
153 // pinned.
154 if (!isLive) {
155 userScrolledUp = false;
156 lastScrollTop = 0;
157 }
158 });
159 </script>
160
161 {#snippet execShellTitle()}
162 {#if highlightedCommandHtml}
163 <span class="font-mono">{@html highlightedCommandHtml}</span>
164 {:else}
165 <span class="font-mono">{execShellMeta?.command}</span>
166 {/if}
167 {/snippet}
168
169 <ToolCallBlock
170 {section}
171 {open}
172 {isStreaming}
173 meta={execShellMeta ? { errorMessage: execShellError } : null}
174 wrapper={CollapsibleTerminalBlock}
175 extraLiveStreaming={isLive}
176 spinIconWhenActive={true}
177 {onToggle}
178 >
179 {#snippet titleSnippet()}
180 {@render execShellTitle()}
181 {/snippet}
182
183 {#snippet children(_meta, ctx)}
184 {#if ctx.isPending}
185 <div class="flex items-start gap-2 text-xs text-muted-foreground/70">
186 <Loader2 class="h-3 w-3 animate-spin" />
187 Running...
188 </div>
189 {:else if execShellError}
190 <div class="flex items-start gap-2 text-xs text-red-600 italic dark:text-red-400">
191 <XCircle class="mt-0.5 h-3 w-3 shrink-0" />
192 <span>{execShellError}</span>
193 </div>
194 {:else if section.toolResult}
195 <div
196 bind:this={scrollEl}
197 class="terminal-output"
198 class:is-clamped={!useFullHeightCodeBlocks}
199 onscroll={handleScrollEvent}
200 >
201 {#each outputLines as line, i (i)}
202 <div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{line.text}</div>
203 {#if line.image}
204 <img
205 src={line.image.base64Url}
206 alt={line.image.name}
207 class="mt-2 mb-2 h-auto max-w-full rounded-lg"
208 loading="lazy"
209 />
210 {/if}
211 {/each}
212
213 {#if isExitCodeFinalLine && execShellExitStatus}
214 <div class={exitBadgeClass}>
215 {#if execShellExitStatus.timedOut}
216 <AlertTriangle class="h-3 w-3" />
217 <span>timed out</span>
218 <span class="exit-sep">&middot;</span>
219 <span>exit {execShellExitStatus.code}</span>
220 {:else if execShellExitStatus.code === 0}
221 <Check class="h-3 w-3" />
222 <span>exit 0</span>
223 {:else}
224 <XCircle class="h-3 w-3" />
225 <span>exit {execShellExitStatus.code}</span>
226 {/if}
227 </div>
228 {/if}
229 </div>
230 {/if}
231 {/snippet}
232 </ToolCallBlock>
233
234 <style>
235 .terminal-output {
236 overscroll-behavior: contain;
237 }
238
239 .terminal-output.is-clamped {
240 max-height: 28rem;
241 overflow-y: auto;
242 scrollbar-gutter: stable;
243 padding-right: 0.25rem;
244 }
245
246 .exit-badge {
247 display: inline-flex;
248 align-items: center;
249 gap: 0.35rem;
250 margin-top: 0.5rem;
251 padding: 0.2rem 0.55rem;
252 border-radius: 0.375rem;
253 font-family: var(--font-mono);
254 font-size: 11px;
255 font-weight: 500;
256 letter-spacing: 0.01em;
257 line-height: 1;
258 }
259
260 .exit-badge.success {
261 background: color-mix(in oklch, var(--color-green-500, #22c55e) 14%, transparent);
262 color: var(--color-green-700, #15803d);
263 }
264
265 :global(.dark) .exit-badge.success {
266 background: color-mix(in oklch, var(--color-green-400, #4ade80) 18%, transparent);
267 color: var(--color-green-300, #86efac);
268 }
269
270 .exit-badge.failure {
271 background: color-mix(in oklch, var(--color-red-500, #ef4444) 14%, transparent);
272 color: var(--color-red-700, #b91c1c);
273 }
274
275 :global(.dark) .exit-badge.failure {
276 background: color-mix(in oklch, var(--color-red-400, #f87171) 18%, transparent);
277 color: var(--color-red-300, #fca5a5);
278 }
279
280 .exit-badge.warning {
281 background: color-mix(in oklch, var(--color-amber-500, #f59e0b) 14%, transparent);
282 color: var(--color-amber-700, #b45309);
283 }
284
285 :global(.dark) .exit-badge.warning {
286 background: color-mix(in oklch, var(--color-amber-400, #fbbf24) 18%, transparent);
287 color: var(--color-amber-300, #fcd34d);
288 }
289
290 .exit-sep {
291 opacity: 0.45;
292 }
293 </style>