ui: Agentic Content UX improvements (#25450)
* feat: Add shimmer text animation for processing state indicators * feat: Redesign CollapsibleContentBlock component with improved UX * feat: Add conditional setting display support with dependsOn field * feat: Add showAgenticTurnStats setting for per-turn statistics * feat: Update ChatMessageAgenticContent with improved UI and new features * feat: Enhance file read tool UI/UX * feat: Refine styling of collapsible content and code preview blocks * feat: add terminal variant to CollapsibleContentBlock * feat: add built-in tools UI registry * feat: extract ChatMessageReasoningBlock and ChatMessageToolCallBlock * refactor: simplify ChatMessageAgenticContent to use extracted blocks * fix: correct markdown content block margin spacing * fix: reorganize SettingsChatFields layout and reset button positioning * fix: use direct map access in agentic store session methods * refactor: remove reasoning preview/throttle system from CollapsibleContentBlock * feat: add auto-scroll to reasoning block and remove showThoughtInProgress * feat: add ChatMessageToolCallDateTime component and support for new tool types * feat: improve auto-scroll reliability in reasoning block with RAF coalescing and MutationObserver * feat: show MCP server favicon for tools without a built-in icon * feat: add search-results parsing utilities and tests * feat: add ChatMessageToolCallSearchResults component * feat: integrate search results rendering into ChatMessageAgenticContent * feat: display tool call input alongside output in ChatMessageToolCallBlock * style: use muted foreground color in reasoning block content * chore: Format * feat: Refine reasoning block layout and make pending thoughts display configurable * feat: Stream tool call code blocks with auto-scroll and handle partial JSON * feat: add streaming permission gate infrastructure * feat: wire permission gate into the agentic loop * fix: bail out on abort and skip already-approved tool calls * fix: clear partial tool calls on abort and savePartialResponse * test: cover partial tool call cleanup end-to-end * refactor: Remove streaming permission gate logic * fix: Correct autoscroll and streaming gates for tool calls and reasoning blocks * refactor: Chat Message Assistant componentization * fix: Show health metadata for disabled MCP servers and promote connections on enable * fix: Inherit global enabled state for missing MCP per-chat overrides * refactor: Cleanup * refactor: Split ChatMessageToolCallBlock into dedicated components * feat: Add live streaming and auto-scroll for tool execution output * feat: Add line numbers and change markers to file edit diffs * chore: Formatting * feat: Add type definitions and utilities for recommended MCP servers * feat: Add recommended MCP servers configuration and storage key * feat: Add McpServerCardCompact component for recommended servers * feat: Add recommended servers section to Add New Server dialog * feat: Update McpServerForm to support authorization requirements * feat: Add select-none classes for text selection prevention * feat: Add recommended MCP server icon assets * refactor: Store dismissed MCP recommendations as a boolean flag * feat: Render tool results as JSON or Markdown based on detected content type * feat: UI improvement * feat: Render search block early and update heading to show execution state * fix: Prevent non-web-search tools from triggering the search UI block * refactor: Cleanup * refactor: Extract hardcoded icon size classes into shared constants * refactor: Extract hardcoded tool result separator into a shared constant * refactor: Tool Calls UI/logic * refactor: Cleanup * refactor: Cleanup * refactor: Cleanup
This commit is contained in:
@@ -384,7 +384,6 @@
|
||||
{isLastAssistantMessage}
|
||||
{message}
|
||||
{toolMessages}
|
||||
messageContent={message.content}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onContinue={handleContinue}
|
||||
onCopy={handleCopy}
|
||||
|
||||
+23
-178
@@ -2,23 +2,20 @@
|
||||
import {
|
||||
ChatMessageAgenticContent,
|
||||
ChatMessageActionIcons,
|
||||
ChatMessageEditForm,
|
||||
ChatMessageStatistics,
|
||||
ModelBadge,
|
||||
ModelsSelectorDropdown
|
||||
ChatMessageAssistantModel,
|
||||
ChatMessageAssistantProcessingInfo,
|
||||
ChatMessageAssistantRawOutput,
|
||||
ChatMessageAssistantStatistics,
|
||||
ChatMessageEditForm
|
||||
} from '$lib/components/app';
|
||||
import { getMessageEditContext } from '$lib/contexts';
|
||||
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
|
||||
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
|
||||
import { copyToClipboard, deriveAgenticSections, modelLoadProgressText } from '$lib/utils';
|
||||
import { AgenticSectionType, ChatMessageStatisticsMode } from '$lib/enums';
|
||||
import { REASONING_TAGS } from '$lib/constants/agentic';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { modelLoadProgressText } from '$lib/utils';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
|
||||
import { hasAgenticContent } from '$lib/utils';
|
||||
|
||||
@@ -33,7 +30,6 @@
|
||||
isLastAssistantMessage?: boolean;
|
||||
message: DatabaseMessage;
|
||||
toolMessages?: DatabaseMessage[];
|
||||
messageContent: string | undefined;
|
||||
onCopy: () => void;
|
||||
onConfirmDelete: () => void;
|
||||
onContinue?: () => void;
|
||||
@@ -54,7 +50,6 @@
|
||||
isLastAssistantMessage = false,
|
||||
message,
|
||||
toolMessages = [],
|
||||
messageContent,
|
||||
onConfirmDelete,
|
||||
onContinue,
|
||||
onCopy,
|
||||
@@ -77,55 +72,11 @@
|
||||
|
||||
let currentConfig = $derived(config());
|
||||
let isRouter = $derived(isRouterMode());
|
||||
|
||||
let showRawOutput = $state(false);
|
||||
|
||||
let rawOutputContent = $derived.by(() => {
|
||||
const sections = deriveAgenticSections(message, toolMessages, [], false);
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const section of sections) {
|
||||
switch (section.type) {
|
||||
case AgenticSectionType.REASONING:
|
||||
case AgenticSectionType.REASONING_PENDING:
|
||||
parts.push(`${REASONING_TAGS.START}\n${section.content}\n${REASONING_TAGS.END}`);
|
||||
break;
|
||||
|
||||
case AgenticSectionType.TEXT:
|
||||
parts.push(section.content);
|
||||
break;
|
||||
|
||||
case AgenticSectionType.TOOL_CALL:
|
||||
case AgenticSectionType.TOOL_CALL_PENDING:
|
||||
case AgenticSectionType.TOOL_CALL_STREAMING: {
|
||||
const callObj: Record<string, unknown> = { name: section.toolName };
|
||||
|
||||
if (section.toolArgs) {
|
||||
try {
|
||||
callObj.arguments = JSON.parse(section.toolArgs);
|
||||
} catch {
|
||||
callObj.arguments = section.toolArgs;
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(JSON.stringify(callObj, null, 2));
|
||||
|
||||
if (section.toolResult) {
|
||||
parts.push(`[Tool Result]\n${section.toolResult}`);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n\n\n');
|
||||
});
|
||||
|
||||
let displayedModel = $derived(message.model ?? null);
|
||||
|
||||
// model being switched to while it loads, so the selector bar tracks it
|
||||
let pendingModel = $state<string | null>(null);
|
||||
|
||||
let isCurrentlyLoading = $derived(isLoading());
|
||||
let isStreaming = $derived(isChatStreaming());
|
||||
let hasNoContent = $derived(!message?.content?.trim());
|
||||
@@ -189,10 +140,6 @@
|
||||
};
|
||||
});
|
||||
|
||||
function handleCopyModel() {
|
||||
void copyToClipboard(displayedModel ?? '');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (showProcessingInfoTop || showProcessingInfoBottom) {
|
||||
processingState.startMonitoring();
|
||||
@@ -211,23 +158,14 @@
|
||||
aria-label="Assistant message with actions"
|
||||
>
|
||||
{#if showProcessingInfoTop}
|
||||
<div class="mt-6 w-full max-w-3xl" in:fade>
|
||||
<div class="processing-container">
|
||||
<span class="processing-text">
|
||||
{modelLoadingText ??
|
||||
processingState.getPromptProgressText() ??
|
||||
processingState.getProcessingMessage() ??
|
||||
'Processing...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="top" />
|
||||
{/if}
|
||||
|
||||
{#if editCtx.isEditing}
|
||||
<ChatMessageEditForm />
|
||||
{:else if message.role === MessageRole.ASSISTANT}
|
||||
{:else}
|
||||
{#if showRawOutput}
|
||||
<pre class="raw-output">{rawOutputContent || ''}</pre>
|
||||
<ChatMessageAssistantRawOutput {message} {toolMessages} />
|
||||
{:else}
|
||||
<ChatMessageAgenticContent
|
||||
{message}
|
||||
@@ -236,78 +174,28 @@
|
||||
{isLastAssistantMessage}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="text-sm whitespace-pre-wrap">
|
||||
{messageContent}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showProcessingInfoBottom}
|
||||
<div class="mt-4 w-full max-w-3xl" in:fade>
|
||||
<div class="processing-container">
|
||||
<span class="processing-text">
|
||||
{modelLoadingText ??
|
||||
processingState.getPromptProgressText() ??
|
||||
processingState.getProcessingMessage() ??
|
||||
'Processing...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="bottom" />
|
||||
{/if}
|
||||
|
||||
<div class="info my-6 grid gap-4 tabular-nums">
|
||||
{#if displayedModel}
|
||||
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
|
||||
{#if isRouter}
|
||||
<ModelsSelectorDropdown
|
||||
currentModel={pendingModel ?? displayedModel}
|
||||
disabled={isLoading()}
|
||||
onModelChange={async (modelId: string, modelName: string) => {
|
||||
const status = modelsStore.getModelStatus(modelId);
|
||||
<ChatMessageAssistantModel
|
||||
{displayedModel}
|
||||
isLoading={isLoading()}
|
||||
{isRouter}
|
||||
{onRegenerate}
|
||||
/>
|
||||
|
||||
if (status !== ServerModelStatus.LOADED) {
|
||||
pendingModel = modelId;
|
||||
|
||||
try {
|
||||
await modelsStore.loadModel(modelId);
|
||||
} finally {
|
||||
pendingModel = null;
|
||||
}
|
||||
}
|
||||
|
||||
onRegenerate(modelName);
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
|
||||
{/if}
|
||||
|
||||
{#if currentConfig.showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
|
||||
{@const agentic = message.timings.agentic}
|
||||
<ChatMessageStatistics
|
||||
mode={ChatMessageStatisticsMode.GENERATION}
|
||||
promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
|
||||
promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
|
||||
predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
|
||||
predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
|
||||
agenticTimings={agentic}
|
||||
/>
|
||||
{:else if isLoading() && currentConfig.showMessageStats}
|
||||
{@const liveStats = processingState.getLiveProcessingStats()}
|
||||
{@const genStats = processingState.getLiveGenerationStats()}
|
||||
|
||||
{#if genStats}
|
||||
<ChatMessageStatistics
|
||||
mode={ChatMessageStatisticsMode.GENERATION}
|
||||
isLive
|
||||
promptTokens={liveStats?.tokensProcessed}
|
||||
promptMs={liveStats?.timeMs}
|
||||
predictedTokens={genStats.tokensGenerated}
|
||||
predictedMs={genStats.timeMs}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
<ChatMessageAssistantStatistics
|
||||
{message}
|
||||
isLoading={isLoading()}
|
||||
{processingState}
|
||||
showMessageStats={currentConfig.showMessageStats}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -353,47 +241,4 @@
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.processing-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.processing-text {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--muted-foreground),
|
||||
var(--foreground),
|
||||
var(--muted-foreground)
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shine 1s linear infinite;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@keyframes shine {
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.raw-output {
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
color: var(--foreground);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { ModelBadge, ModelsSelectorDropdown } from '$lib/components/app';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
|
||||
interface Props {
|
||||
displayedModel: string | null;
|
||||
isRouter: boolean;
|
||||
isLoading: boolean;
|
||||
onRegenerate: (modelOverride?: string) => void;
|
||||
}
|
||||
|
||||
let { displayedModel, isRouter, isLoading, onRegenerate }: Props = $props();
|
||||
|
||||
let pendingModel = $state<string | null>(null);
|
||||
|
||||
function handleCopyModel() {
|
||||
void copyToClipboard(displayedModel ?? '');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isRouter}
|
||||
<ModelsSelectorDropdown
|
||||
currentModel={pendingModel ?? displayedModel}
|
||||
disabled={isLoading}
|
||||
onModelChange={async (modelId: string, modelName: string) => {
|
||||
const status = modelsStore.getModelStatus(modelId);
|
||||
|
||||
if (status !== ServerModelStatus.LOADED) {
|
||||
pendingModel = modelId;
|
||||
|
||||
try {
|
||||
await modelsStore.loadModel(modelId);
|
||||
} finally {
|
||||
pendingModel = null;
|
||||
}
|
||||
}
|
||||
|
||||
onRegenerate(modelName);
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<ModelBadge model={displayedModel || undefined} onclick={handleCopyModel} />
|
||||
{/if}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { UseProcessingStateReturn } from '$lib/hooks/use-processing-state.svelte';
|
||||
|
||||
interface Props {
|
||||
modelLoadingText: string | null;
|
||||
processingState: UseProcessingStateReturn;
|
||||
position: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
let { modelLoadingText, processingState, position }: Props = $props();
|
||||
|
||||
const marginClass = position === 'top' ? 'mt-6' : 'mt-4';
|
||||
</script>
|
||||
|
||||
<div class="{marginClass} w-full max-w-3xl" in:fade>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<span class="shimmer-text text-sm">
|
||||
{modelLoadingText ??
|
||||
processingState.getPromptProgressText() ??
|
||||
processingState.getProcessingMessage() ??
|
||||
'Processing...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { deriveAgenticSections, buildAssistantRawOutput } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
message: DatabaseMessage;
|
||||
toolMessages?: DatabaseMessage[];
|
||||
}
|
||||
|
||||
let { message, toolMessages = [] }: Props = $props();
|
||||
|
||||
let rawOutputContent = $derived.by(() => {
|
||||
const sections = deriveAgenticSections(message, toolMessages, [], false);
|
||||
return buildAssistantRawOutput(sections);
|
||||
});
|
||||
</script>
|
||||
|
||||
<pre class="raw-output">{rawOutputContent || ''}</pre>
|
||||
|
||||
<style>
|
||||
.raw-output {
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
color: var(--foreground);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { ChatMessageStatistics } from '$lib/components/app';
|
||||
import { ChatMessageStatisticsMode } from '$lib/enums';
|
||||
import type { UseProcessingStateReturn } from '$lib/hooks/use-processing-state.svelte';
|
||||
|
||||
interface Props {
|
||||
message: DatabaseMessage;
|
||||
isLoading: boolean;
|
||||
processingState: UseProcessingStateReturn;
|
||||
showMessageStats: boolean;
|
||||
}
|
||||
|
||||
let { message, isLoading, processingState, showMessageStats }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
|
||||
{@const agentic = message.timings.agentic}
|
||||
<ChatMessageStatistics
|
||||
mode={ChatMessageStatisticsMode.GENERATION}
|
||||
promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
|
||||
promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
|
||||
predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
|
||||
predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
|
||||
agenticTimings={agentic}
|
||||
/>
|
||||
{:else if isLoading && showMessageStats}
|
||||
{@const liveStats = processingState.getLiveProcessingStats()}
|
||||
{@const genStats = processingState.getLiveGenerationStats()}
|
||||
|
||||
{#if genStats}
|
||||
<ChatMessageStatistics
|
||||
mode={ChatMessageStatisticsMode.GENERATION}
|
||||
isLive
|
||||
promptTokens={liveStats?.tokensProcessed}
|
||||
promptMs={liveStats?.timeMs}
|
||||
predictedTokens={genStats.tokensGenerated}
|
||||
predictedMs={genStats.timeMs}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import {
|
||||
extractSearchQuery,
|
||||
extractSearchResults,
|
||||
isWebSearchToolName,
|
||||
type AgenticSection
|
||||
} from '$lib/utils';
|
||||
import type { DatabaseMessageExtra } from '$lib/types';
|
||||
import ChatMessageToolCallBlockDefault from './ChatMessageToolCallBlockDefault.svelte';
|
||||
import ChatMessageToolCallBlockEditFile from './ChatMessageToolCallBlockEditFile.svelte';
|
||||
import ChatMessageToolCallBlockExecShellCommand from './ChatMessageToolCallBlockExecShellCommand.svelte';
|
||||
import ChatMessageToolCallBlockFileGlobSearch from './ChatMessageToolCallBlockFileGlobSearch.svelte';
|
||||
import ChatMessageToolCallBlockGetDatetime from './ChatMessageToolCallBlockGetDatetime.svelte';
|
||||
import ChatMessageToolCallBlockGrepSearch from './ChatMessageToolCallBlockGrepSearch.svelte';
|
||||
import ChatMessageToolCallBlockReadFile from './ChatMessageToolCallBlockReadFile.svelte';
|
||||
import ChatMessageToolCallBlockRunJavascript from './ChatMessageToolCallBlockRunJavascript.svelte';
|
||||
import ChatMessageToolCallBlockSearchResults from './ChatMessageToolCallBlockSearchResults.svelte';
|
||||
import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
open: boolean;
|
||||
isStreaming: boolean;
|
||||
isExecuting?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { section, attachments, open, isStreaming, isExecuting, onToggle }: Props = $props();
|
||||
|
||||
const searchResults = $derived(extractSearchResults(section.toolResult));
|
||||
const searchQuery = $derived(extractSearchQuery(section.toolArgs));
|
||||
const isSearchCall = $derived(
|
||||
searchResults.length > 0 || (searchQuery.length > 0 && isWebSearchToolName(section.toolName))
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if isSearchCall}
|
||||
<ChatMessageToolCallBlockSearchResults {section} {open} {isStreaming} {onToggle} />
|
||||
{:else if section.toolName === BuiltInTool.GET_DATETIME}
|
||||
<ChatMessageToolCallBlockGetDatetime {section} {isStreaming} />
|
||||
{:else if section.toolName === BuiltInTool.READ_FILE}
|
||||
<ChatMessageToolCallBlockReadFile {section} {open} {isStreaming} {onToggle} />
|
||||
{:else if section.toolName === BuiltInTool.EDIT_FILE}
|
||||
<ChatMessageToolCallBlockEditFile {section} {open} {isStreaming} {onToggle} />
|
||||
{:else if section.toolName === BuiltInTool.WRITE_FILE}
|
||||
<ChatMessageToolCallBlockWriteFile {section} {open} {isStreaming} {onToggle} />
|
||||
{:else if section.toolName === BuiltInTool.EXEC_SHELL_COMMAND}
|
||||
<ChatMessageToolCallBlockExecShellCommand
|
||||
{section}
|
||||
{open}
|
||||
{isStreaming}
|
||||
{isExecuting}
|
||||
{attachments}
|
||||
{onToggle}
|
||||
/>
|
||||
{:else if section.toolName === BuiltInTool.FILE_GLOB_SEARCH}
|
||||
<ChatMessageToolCallBlockFileGlobSearch {section} {open} {isStreaming} {onToggle} />
|
||||
{:else if section.toolName === BuiltInTool.GREP_SEARCH}
|
||||
<ChatMessageToolCallBlockGrepSearch {section} {open} {isStreaming} {onToggle} />
|
||||
{:else if section.toolName === BuiltInTool.RUN_JAVASCRIPT}
|
||||
<ChatMessageToolCallBlockRunJavascript {section} {open} {isStreaming} {onToggle} />
|
||||
{:else}
|
||||
<ChatMessageToolCallBlockDefault {section} {open} {isStreaming} {attachments} {onToggle} />
|
||||
{/if}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
// Fall-through renderer for tool calls without a dedicated block.
|
||||
// Renders section.toolArgs / section.toolResult directly using the
|
||||
// shared chrome shell.
|
||||
|
||||
import { Loader2 } from '@lucide/svelte';
|
||||
import { MarkdownContent, SyntaxHighlightedCode } from '$lib/components/app';
|
||||
import { FileTypeText, ToolResultKind } from '$lib/enums';
|
||||
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
|
||||
import {
|
||||
classifyToolResult,
|
||||
formatJsonPretty,
|
||||
parseToolResultWithImages,
|
||||
type AgenticSection,
|
||||
type ToolResultLine
|
||||
} from '$lib/utils';
|
||||
import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
|
||||
import type { DatabaseMessageExtra } from '$lib/types';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
open: boolean;
|
||||
isStreaming: boolean;
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { section, open, isStreaming, attachments, onToggle }: Props = $props();
|
||||
|
||||
const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
|
||||
|
||||
const parsedLines: ToolResultLine[] = $derived(
|
||||
section.toolResult ? parseToolResultWithImages(section.toolResult, attachments) : []
|
||||
);
|
||||
const outputKind = $derived(classifyToolResult(section.toolResult));
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={null} {title} {onToggle}>
|
||||
{#snippet children(_meta, ctx)}
|
||||
{#if ctx.isStreamingCall}
|
||||
<div class="mb-2 flex items-center gap-2 text-xs text-muted-foreground/70">
|
||||
<span>Input</span>
|
||||
{#if ctx.isStreaming}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if section.toolArgs}
|
||||
<SyntaxHighlightedCode
|
||||
code={formatJsonPretty(section.toolArgs)}
|
||||
language={FileTypeText.JSON}
|
||||
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||
streaming={ctx.isCodeStreaming}
|
||||
/>
|
||||
{:else if ctx.isStreaming}
|
||||
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
|
||||
Receiving arguments...
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="rounded bg-yellow-500/10 p-2 text-xs text-yellow-600 italic dark:text-yellow-400"
|
||||
>
|
||||
Response was truncated
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{@const showInput = Boolean(section.toolArgs)}
|
||||
{#if showInput}
|
||||
<div class="mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70">
|
||||
<span>Input</span>
|
||||
</div>
|
||||
<SyntaxHighlightedCode
|
||||
code={formatJsonPretty(section.toolArgs ?? '')}
|
||||
language={FileTypeText.JSON}
|
||||
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||
streaming={ctx.isCodeStreaming}
|
||||
/>
|
||||
{/if}
|
||||
<div
|
||||
class={showInput
|
||||
? 'mt-4 mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70'
|
||||
: 'mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70'}
|
||||
>
|
||||
<span>Output</span>
|
||||
{#if ctx.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if ctx.isPending}
|
||||
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
|
||||
Waiting for result...
|
||||
</div>
|
||||
{:else if section.toolResult}
|
||||
{#if outputKind === ToolResultKind.JSON}
|
||||
<SyntaxHighlightedCode
|
||||
code={formatJsonPretty(section.toolResult)}
|
||||
language={FileTypeText.JSON}
|
||||
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||
/>
|
||||
{:else if outputKind === ToolResultKind.MARKDOWN}
|
||||
<MarkdownContent content={section.toolResult} {attachments} />
|
||||
{:else}
|
||||
<div class="overflow-auto">
|
||||
{#each parsedLines as line, i (i)}
|
||||
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
|
||||
{line.text}
|
||||
</div>
|
||||
{#if line.image}
|
||||
<img
|
||||
src={line.image.base64Url}
|
||||
alt={line.image.name}
|
||||
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">No output</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ToolCallBlock>
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
import { XCircle } from '@lucide/svelte';
|
||||
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
|
||||
import { computeLineDiff, prefixFor, type AgenticSection } from '$lib/utils';
|
||||
import { parseEditFileMeta } from './parsers/edit-file';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
open: boolean;
|
||||
isStreaming: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
|
||||
const editFileMeta = $derived(parseEditFileMeta(section));
|
||||
|
||||
const editDiffs = $derived(
|
||||
(editFileMeta?.edits ?? []).map((edit) => computeLineDiff(edit.oldText, edit.newText))
|
||||
);
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={editFileMeta} {onToggle}>
|
||||
{#snippet titleSnippet()}
|
||||
<span class="text-muted-foreground">Edit file </span>
|
||||
<span class="font-mono">{editFileMeta?.filePath}</span>
|
||||
{#if editFileMeta?.errorMessage}
|
||||
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet children(meta, _ctx)}
|
||||
{#if meta?.errorMessage}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||
>
|
||||
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
<span>{meta.errorMessage}</span>
|
||||
</div>
|
||||
{:else if meta && meta.edits.length > 0}
|
||||
{#each editDiffs as diffLines, ei (ei)}
|
||||
<div class={ei === 0 ? '' : 'mt-3'}>
|
||||
<div class="mb-1.5 text-xs text-muted-foreground/70 italic">
|
||||
Edit {ei + 1} of {meta.edits.length}
|
||||
</div>
|
||||
<div class="diff-block" style:max-height={MAX_HEIGHT_CODE_BLOCK}>
|
||||
<div class="diff-pre">
|
||||
{#each diffLines as line, li (li)}
|
||||
<div class="diff-line diff-{line.kind}">
|
||||
<span class="diff-old-num">{line.oldLine ?? ''}</span>
|
||||
<span class="diff-marker">{prefixFor(line.kind)}</span>
|
||||
<span class="diff-new-num">{line.newLine ?? ''}</span>
|
||||
<span class="diff-text">{line.text || ' '}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||
{#if meta.resultMessage}
|
||||
{meta.resultMessage}{meta.editsApplied != null ? RESULT_STAT_SEPARATOR : ''}{/if}
|
||||
{#if meta.editsApplied != null}
|
||||
<span class="font-mono">{meta.editsApplied}</span>
|
||||
{meta.editsApplied === 1 ? 'edit' : 'edits'} applied
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">No edits</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ToolCallBlock>
|
||||
|
||||
<style>
|
||||
.diff-block {
|
||||
overflow: auto;
|
||||
border-radius: 0.75rem;
|
||||
border-width: 1px;
|
||||
border-color: color-mix(in oklch, var(--border) 30%, transparent);
|
||||
background: var(--code-background);
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .diff-block {
|
||||
border-color: color-mix(in oklch, var(--border) 20%, transparent);
|
||||
}
|
||||
|
||||
/* Each row is a 4-column grid: old-line#, marker, new-line#, text.
|
||||
* The gutters stay fixed-width so the text column lines up unversally. */
|
||||
.diff-line {
|
||||
display: grid;
|
||||
grid-template-columns: 3.25rem 1.5rem 3.25rem 1fr;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.65;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.diff-old-num,
|
||||
.diff-new-num {
|
||||
text-align: right;
|
||||
padding-right: 0.5rem;
|
||||
user-select: none;
|
||||
color: color-mix(in oklch, var(--muted-foreground) 70%, transparent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.diff-marker {
|
||||
text-align: center;
|
||||
color: color-mix(in oklch, var(--muted-foreground) 70%, transparent);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.diff-line.diff-add {
|
||||
background-color: #f0fff4;
|
||||
color: #22863a;
|
||||
}
|
||||
.diff-line.diff-add .diff-new-num,
|
||||
.diff-line.diff-add .diff-marker {
|
||||
color: #22863a;
|
||||
}
|
||||
|
||||
.diff-line.diff-remove {
|
||||
background-color: #ffeef0;
|
||||
color: #b31d28;
|
||||
}
|
||||
.diff-line.diff-remove .diff-old-num,
|
||||
.diff-line.diff-remove .diff-marker {
|
||||
color: #b31d28;
|
||||
}
|
||||
|
||||
.diff-line.diff-add .diff-old-num,
|
||||
.diff-line.diff-remove .diff-new-num {
|
||||
/* Empty gutter columns for add/remove rows mirror git unification
|
||||
* (added lines don't have an old number, removed lines don't have a
|
||||
* new number). Keep them visible so columns stay aligned across
|
||||
* mixed rows. */
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.diff-text {
|
||||
padding-left: 0.4rem;
|
||||
padding-right: 0.5rem;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:global(.dark) .diff-line.diff-add {
|
||||
background-color: #033a16;
|
||||
color: #aff5b4;
|
||||
}
|
||||
:global(.dark) .diff-line.diff-add .diff-new-num,
|
||||
:global(.dark) .diff-line.diff-add .diff-marker {
|
||||
color: #aff5b4;
|
||||
}
|
||||
:global(.dark) .diff-line.diff-remove {
|
||||
background-color: #67060c;
|
||||
color: #ffdcd7;
|
||||
}
|
||||
:global(.dark) .diff-line.diff-remove .diff-old-num,
|
||||
:global(.dark) .diff-line.diff-remove .diff-marker {
|
||||
color: #ffdcd7;
|
||||
}
|
||||
</style>
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
<script lang="ts">
|
||||
// Block for `exec_shell_command`. Unlike the other tools, this
|
||||
// renderer uses CollapsibleTerminalBlock (terminal-style frame)
|
||||
// and treats "live" output chunks as active even after the call
|
||||
// resolved, so the spinner stays on while stdout is still flowing.
|
||||
// The scroll-to-bottom auto-scroll logic mirrors what was here
|
||||
// before extraction.
|
||||
|
||||
import { Check, Loader2, XCircle, AlertTriangle } from '@lucide/svelte';
|
||||
import { CollapsibleTerminalBlock } from '$lib/components/app';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
|
||||
import {
|
||||
highlightCode,
|
||||
isExitCodeSummaryLine,
|
||||
parseExecShellCommandError,
|
||||
parseExecShellCommandExitStatus,
|
||||
parseToolResultWithImages,
|
||||
type AgenticSection,
|
||||
type ExecShellExitStatus,
|
||||
type ToolResultLine
|
||||
} from '$lib/utils';
|
||||
import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
|
||||
import type { DatabaseMessageExtra } from '$lib/types';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
open: boolean;
|
||||
isStreaming: boolean;
|
||||
/** True while the agentic loop is streaming output chunks for THIS
|
||||
* tool call. Drives max-height + auto-scroll while true; releases
|
||||
* them when the loop reports this call as done. */
|
||||
isExecuting?: boolean;
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { section, open, isStreaming, isExecuting = false, attachments, onToggle }: Props = $props();
|
||||
|
||||
// `isLive` covers all in-flight phases: pre-chunk spinner and
|
||||
// streaming itself. Frozen output (tool done while agent continues)
|
||||
// is not live.
|
||||
const isLive = $derived(isExecuting);
|
||||
|
||||
const execShellMeta = $derived(parseExecShellCommandMeta(section));
|
||||
const execShellError = $derived(parseExecShellCommandError(section.toolResult));
|
||||
const execShellExitStatus: ExecShellExitStatus | undefined = $derived(
|
||||
parseExecShellCommandExitStatus(section.toolResult)
|
||||
);
|
||||
|
||||
const parsedLines: ToolResultLine[] = $derived(
|
||||
section.toolResult ? parseToolResultWithImages(section.toolResult, attachments) : []
|
||||
);
|
||||
|
||||
// Drop the trailing "[exit code: N]" line - rendered as a colored
|
||||
// badge below. During streaming we keep it so a partial stream still
|
||||
// shows the status once the final chunk lands.
|
||||
const outputLines: ToolResultLine[] = $derived(
|
||||
execShellExitStatus && parsedLines.length > 0
|
||||
? parsedLines.slice(0, parsedLines.length - 1)
|
||||
: parsedLines
|
||||
);
|
||||
|
||||
const isExitCodeFinalLine = $derived(
|
||||
execShellExitStatus !== undefined &&
|
||||
parsedLines.length > 0 &&
|
||||
isExitCodeSummaryLine(parsedLines[parsedLines.length - 1].text, execShellExitStatus)
|
||||
);
|
||||
|
||||
// Highlight just the command for the title; the (typically large)
|
||||
// output blob uses bare monospace to skip hljs per-line highlighting.
|
||||
const highlightedCommandHtml = $derived(
|
||||
execShellMeta ? highlightCode(execShellMeta.command, 'bash') : ''
|
||||
);
|
||||
|
||||
const exitBadgeClass = $derived(
|
||||
execShellExitStatus?.timedOut
|
||||
? 'exit-badge warning'
|
||||
: execShellExitStatus?.code === 0
|
||||
? 'exit-badge success'
|
||||
: 'exit-badge failure'
|
||||
);
|
||||
|
||||
const useFullHeightCodeBlocks = $derived(
|
||||
Boolean(config()[SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS])
|
||||
);
|
||||
|
||||
const autoScroll = $derived(isLive && !useFullHeightCodeBlocks);
|
||||
|
||||
const SCROLL_BOTTOM_THRESHOLD_PX = TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX;
|
||||
|
||||
let scrollEl: HTMLDivElement | undefined = $state();
|
||||
let userScrolledUp = $state(false);
|
||||
let lastScrollTop = 0;
|
||||
let pendingFrame: number | null = null;
|
||||
|
||||
function isAtBottom(): boolean {
|
||||
if (!scrollEl) return false;
|
||||
return (
|
||||
scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
|
||||
SCROLL_BOTTOM_THRESHOLD_PX
|
||||
);
|
||||
}
|
||||
|
||||
function scrollToBottomOnFrame() {
|
||||
if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
|
||||
pendingFrame = requestAnimationFrame(() => {
|
||||
pendingFrame = null;
|
||||
|
||||
// Re-check on rAF - user may scroll between scheduling and paint.
|
||||
if (scrollEl && !userScrolledUp) {
|
||||
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleScrollEvent() {
|
||||
if (!scrollEl) return;
|
||||
const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
|
||||
if (isScrollingUp && !isAtBottom()) {
|
||||
userScrolledUp = true;
|
||||
} else if (isAtBottom()) {
|
||||
userScrolledUp = false;
|
||||
}
|
||||
lastScrollTop = scrollEl.scrollTop;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void section.toolResult;
|
||||
if (!scrollEl || !autoScroll) return;
|
||||
scrollToBottomOnFrame();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Catch layout changes that don't touch toolResult (line-wrap
|
||||
// reflow, image attaches, hljs settle).
|
||||
if (!scrollEl || !autoScroll) return;
|
||||
|
||||
const observer = new MutationObserver(() => scrollToBottomOnFrame());
|
||||
observer.observe(scrollEl, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Reset on stream end so the next render (full-height) starts
|
||||
// pinned.
|
||||
if (!isLive) {
|
||||
userScrolledUp = false;
|
||||
lastScrollTop = 0;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet execShellTitle()}
|
||||
{#if highlightedCommandHtml}
|
||||
<span class="font-mono">{@html highlightedCommandHtml}</span>
|
||||
{:else}
|
||||
<span class="font-mono">{execShellMeta?.command}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<ToolCallBlock
|
||||
{section}
|
||||
{open}
|
||||
{isStreaming}
|
||||
meta={execShellMeta ? { errorMessage: execShellError } : null}
|
||||
wrapper={CollapsibleTerminalBlock}
|
||||
extraLiveStreaming={isLive}
|
||||
spinIconWhenActive={true}
|
||||
{onToggle}
|
||||
>
|
||||
{#snippet titleSnippet()}
|
||||
{@render execShellTitle()}
|
||||
{/snippet}
|
||||
|
||||
{#snippet children(_meta, ctx)}
|
||||
{#if ctx.isPending}
|
||||
<div class="flex items-start gap-2 text-xs text-muted-foreground/70">
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
Running...
|
||||
</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" />
|
||||
<span>{execShellError}</span>
|
||||
</div>
|
||||
{:else if section.toolResult}
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
class="terminal-output"
|
||||
class:is-clamped={!useFullHeightCodeBlocks}
|
||||
onscroll={handleScrollEvent}
|
||||
>
|
||||
{#each outputLines as line, i (i)}
|
||||
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{line.text}</div>
|
||||
{#if line.image}
|
||||
<img
|
||||
src={line.image.base64Url}
|
||||
alt={line.image.name}
|
||||
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if isExitCodeFinalLine && execShellExitStatus}
|
||||
<div class={exitBadgeClass}>
|
||||
{#if execShellExitStatus.timedOut}
|
||||
<AlertTriangle class="h-3 w-3" />
|
||||
<span>timed out</span>
|
||||
<span class="exit-sep">·</span>
|
||||
<span>exit {execShellExitStatus.code}</span>
|
||||
{:else if execShellExitStatus.code === 0}
|
||||
<Check class="h-3 w-3" />
|
||||
<span>exit 0</span>
|
||||
{:else}
|
||||
<XCircle class="h-3 w-3" />
|
||||
<span>exit {execShellExitStatus.code}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ToolCallBlock>
|
||||
|
||||
<style>
|
||||
.terminal-output {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.terminal-output.is-clamped {
|
||||
max-height: 28rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.exit-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 0.375rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.exit-badge.success {
|
||||
background: color-mix(in oklch, var(--color-green-500, #22c55e) 14%, transparent);
|
||||
color: var(--color-green-700, #15803d);
|
||||
}
|
||||
|
||||
:global(.dark) .exit-badge.success {
|
||||
background: color-mix(in oklch, var(--color-green-400, #4ade80) 18%, transparent);
|
||||
color: var(--color-green-300, #86efac);
|
||||
}
|
||||
|
||||
.exit-badge.failure {
|
||||
background: color-mix(in oklch, var(--color-red-500, #ef4444) 14%, transparent);
|
||||
color: var(--color-red-700, #b91c1c);
|
||||
}
|
||||
|
||||
:global(.dark) .exit-badge.failure {
|
||||
background: color-mix(in oklch, var(--color-red-400, #f87171) 18%, transparent);
|
||||
color: var(--color-red-300, #fca5a5);
|
||||
}
|
||||
|
||||
.exit-badge.warning {
|
||||
background: color-mix(in oklch, var(--color-amber-500, #f59e0b) 14%, transparent);
|
||||
color: var(--color-amber-700, #b45309);
|
||||
}
|
||||
|
||||
:global(.dark) .exit-badge.warning {
|
||||
background: color-mix(in oklch, var(--color-amber-400, #fbbf24) 18%, transparent);
|
||||
color: var(--color-amber-300, #fcd34d);
|
||||
}
|
||||
|
||||
.exit-sep {
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { XCircle } from '@lucide/svelte';
|
||||
import { type AgenticSection } from '$lib/utils';
|
||||
import { parseFileGlobSearchMeta } from './parsers/file-glob-search';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
open: boolean;
|
||||
isStreaming: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
|
||||
const fileGlobMeta = $derived(parseFileGlobSearchMeta(section));
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={fileGlobMeta} {onToggle}>
|
||||
{#snippet titleSnippet()}
|
||||
{#if fileGlobMeta}
|
||||
<span class="text-muted-foreground"
|
||||
>{fileGlobMeta.include === '**' ? 'List files' : 'Search files'} </span
|
||||
>
|
||||
{#if fileGlobMeta.include !== '**'}
|
||||
<span class="font-mono">{fileGlobMeta.include}</span>
|
||||
{/if}
|
||||
<span class="text-muted-foreground"> in </span>
|
||||
<span class="font-mono">{fileGlobMeta.path}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet children(meta, ctx)}
|
||||
{#if ctx.isPending}
|
||||
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
|
||||
Searching...
|
||||
</div>
|
||||
{:else if meta?.errorMessage}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||
>
|
||||
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
<span>{meta.errorMessage}</span>
|
||||
</div>
|
||||
{:else if meta && meta.matches.length > 0}
|
||||
<div class="max-h-96 overflow-auto">
|
||||
{#each meta.matches as match, i (i)}
|
||||
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{match}</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||
Total matches: <span class="font-mono">{meta.totalMatches ?? meta.matches.length}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-muted-foreground/70 italic">No matches</div>
|
||||
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||
Total matches: <span class="font-mono">{meta?.totalMatches ?? 0}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ToolCallBlock>
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { Clock, Loader2 } from '@lucide/svelte';
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
let { section, isStreaming = false }: Props = $props();
|
||||
|
||||
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
|
||||
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
|
||||
const showSpinner = $derived(isPending || (isStreamingCall && isStreaming));
|
||||
|
||||
type GetDatetimeMeta = {
|
||||
dateString?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
function parseGetDatetimeMeta(toolResultString: string | undefined): GetDatetimeMeta {
|
||||
if (!toolResultString) return {};
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(toolResultString);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.error === 'string') return { errorMessage: obj.error };
|
||||
if (typeof obj.result === 'string') return { dateString: obj.result.trim() };
|
||||
}
|
||||
} catch {
|
||||
return { dateString: toolResultString.trim() };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
const dateMeta = $derived(parseGetDatetimeMeta(section.toolResult));
|
||||
</script>
|
||||
|
||||
<div class="text-muted-foreground flex items-center gap-2 py-1.5">
|
||||
<Clock class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
|
||||
{#if showSpinner}
|
||||
<span class="text-foreground/80 text-sm font-medium">Current time</span>
|
||||
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
|
||||
{:else if dateMeta.errorMessage}
|
||||
<span class="text-foreground/80 text-sm font-medium">Current time </span>
|
||||
<span class="text-red-600 text-xs italic dark:text-red-400">- {dateMeta.errorMessage}</span
|
||||
>
|
||||
{:else if dateMeta.dateString}
|
||||
<span class="text-foreground/80 text-sm font-medium">Current time is </span>
|
||||
<span class="font-mono text-foreground/90 text-sm">{dateMeta.dateString}</span>
|
||||
{:else}
|
||||
<span class="text-foreground/80 text-sm font-medium">Current time</span>
|
||||
{/if}
|
||||
</div>
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { XCircle } from '@lucide/svelte';
|
||||
import { type AgenticSection } from '$lib/utils';
|
||||
import { parseGrepSearchMeta } from './parsers/grep-search';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
open: boolean;
|
||||
isStreaming: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
|
||||
const grepMeta = $derived(parseGrepSearchMeta(section));
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={grepMeta} {onToggle}>
|
||||
{#snippet titleSnippet()}
|
||||
{#if grepMeta}
|
||||
<span class="text-muted-foreground">Search for </span>
|
||||
<span class="font-mono">{grepMeta.pattern}</span>
|
||||
<span class="text-muted-foreground"> in </span>
|
||||
<span class="font-mono">{grepMeta.path}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet children(meta, ctx)}
|
||||
{#if ctx.isPending}
|
||||
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
|
||||
Searching...
|
||||
</div>
|
||||
{:else if meta?.errorMessage}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||
>
|
||||
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
<span>{meta.errorMessage}</span>
|
||||
</div>
|
||||
{:else if meta && meta.matches.length > 0}
|
||||
<div class="max-h-96 overflow-auto">
|
||||
{#each meta.matches as match, mi (mi)}
|
||||
<div class="font-mono text-[11px] leading-relaxed">
|
||||
<span class="text-muted-foreground/70">{match.file}</span>
|
||||
{#if meta.showLineNumbers && match.line != null}
|
||||
<span class="text-muted-foreground/70">:{match.line}</span>
|
||||
{/if}
|
||||
<span class="text-muted-foreground/70">:</span>
|
||||
<span>{match.content}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||
Total matches: <span class="font-mono">{meta.totalMatches ?? meta.matches.length}</span>
|
||||
{#if meta.showLineNumbers}
|
||||
<span class="italic">(with line numbers)</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-muted-foreground/70 italic">No matches</div>
|
||||
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||
Total matches: <span class="font-mono">{meta?.totalMatches ?? 0}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ToolCallBlock>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { SyntaxHighlightedCode } from '$lib/components/app';
|
||||
import { DEFAULT_LANGUAGE, MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
|
||||
import { type AgenticSection } from '$lib/utils';
|
||||
import { parseReadFileMeta } from './parsers/read-file';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
open: boolean;
|
||||
isStreaming: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
|
||||
const readFileMeta = $derived(parseReadFileMeta(section));
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={readFileMeta} {onToggle}>
|
||||
{#snippet titleSnippet()}
|
||||
<span class="text-muted-foreground">Read file </span>
|
||||
<span class="font-mono">{readFileMeta?.fileName}</span>
|
||||
{#if readFileMeta?.lineRange}
|
||||
<span class="text-muted-foreground"
|
||||
> (lines {readFileMeta.lineRange.start}-{readFileMeta.lineRange.end})</span
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet children(_meta, _ctx)}
|
||||
{#if section.toolResult}
|
||||
<SyntaxHighlightedCode
|
||||
code={section.toolResult}
|
||||
language={readFileMeta?.language ?? DEFAULT_LANGUAGE}
|
||||
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||
/>
|
||||
{:else}
|
||||
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
|
||||
Waiting for file content...
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ToolCallBlock>
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { XCircle, Terminal } from '@lucide/svelte';
|
||||
import { SyntaxHighlightedCode } from '$lib/components/app';
|
||||
import { FileTypeText } from '$lib/enums';
|
||||
import { MAX_HEIGHT_CODE_BLOCK } from '$lib/constants';
|
||||
import { getBuiltinToolUi, type AgenticSection } from '$lib/utils';
|
||||
import { parseRunJavascriptMeta } from './parsers/run-javascript';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
open: boolean;
|
||||
isStreaming: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
|
||||
const runJsMeta = $derived(parseRunJavascriptMeta(section));
|
||||
const title = $derived(getBuiltinToolUi(section.toolName)?.label ?? section.toolName ?? '');
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={runJsMeta} {title} {onToggle}>
|
||||
{#snippet children(meta, ctx)}
|
||||
{#if ctx.isPending}
|
||||
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">Running...</div>
|
||||
{:else if meta?.errorMessage}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||
>
|
||||
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
<span>{meta.errorMessage}</span>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<SyntaxHighlightedCode
|
||||
code={meta.code}
|
||||
language={FileTypeText.JAVASCRIPT}
|
||||
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||
streaming={ctx.isCodeStreaming}
|
||||
/>
|
||||
</div>
|
||||
{:else if meta}
|
||||
<SyntaxHighlightedCode
|
||||
code={meta.code}
|
||||
language={FileTypeText.JAVASCRIPT}
|
||||
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||
streaming={ctx.isCodeStreaming}
|
||||
/>
|
||||
<div class="mb-2 mt-3 flex items-center gap-2 text-xs text-muted-foreground/70">
|
||||
<Terminal class="h-3 w-3" />
|
||||
<span>Console</span>
|
||||
{#if meta.timeoutMs != null}
|
||||
<span class="font-mono">· timeout {meta.timeoutMs} ms</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if section.toolResult}
|
||||
<div class="mt-1">
|
||||
<SyntaxHighlightedCode
|
||||
code={section.toolResult}
|
||||
language={FileTypeText.JAVASCRIPT}
|
||||
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">No output</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ToolCallBlock>
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
|
||||
import { Globe, Loader2 } from '@lucide/svelte';
|
||||
import { CollapsibleContentBlock } from '$lib/components/app';
|
||||
import * as HoverCard from '$lib/components/ui/hover-card';
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import {
|
||||
extractSearchResults,
|
||||
extractSearchQuery,
|
||||
faviconForUrl,
|
||||
sanitizeExternalUrl,
|
||||
type SearchResult,
|
||||
type AgenticSection
|
||||
} from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
open?: boolean;
|
||||
isStreaming?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { section, open = $bindable(false), isStreaming = false, onToggle }: Props = $props();
|
||||
|
||||
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
|
||||
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
|
||||
const showSpinner = $derived(isPending || (isStreamingCall && isStreaming));
|
||||
|
||||
const results: SearchResult[] = $derived(extractSearchResults(section.toolResult));
|
||||
const query = $derived(extractSearchQuery(section.toolArgs));
|
||||
|
||||
// Same icon-resolution chain as ChatMessageToolCallBlockDefault so
|
||||
// MCP-server branding is consistent across both views. Spinner wins
|
||||
// while the call is in flight so the user sees execution status.
|
||||
const iconUrl = $derived(showSpinner ? null : mcpStore.getServerFaviconForTool(section.toolName));
|
||||
const icon = $derived(showSpinner ? Loader2 : undefined);
|
||||
const iconClass = $derived(showSpinner ? ICON_CLASS_SPIN : ICON_CLASS_DEFAULT);
|
||||
|
||||
// Verb reflects state: "Searching" while the call is in flight, "Searched"
|
||||
// once results (or a definitive empty response) have arrived. Lets the
|
||||
// heading read as a live progress indicator rather than a completed
|
||||
// retrospective.
|
||||
const title = $derived.by(() => {
|
||||
const verb = showSpinner ? 'Searching' : 'Searched';
|
||||
return query ? `${verb} web for "${query}"` : `${verb} web`;
|
||||
});
|
||||
|
||||
function hideBrokenIcon(event: Event) {
|
||||
(event.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}
|
||||
|
||||
function formatPublishDate(iso: string | undefined): string | null {
|
||||
if (!iso) return null;
|
||||
try {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return iso;
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function hostFor(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasDetails(result: SearchResult): boolean {
|
||||
return Boolean(result.highlights || result.published || result.author);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet pill(result: SearchResult)}
|
||||
{@const faviconUrl = faviconForUrl(result.url)}
|
||||
{@const safeUrl = sanitizeExternalUrl(result.url)}
|
||||
{@const showHoverCard = safeUrl !== null && hasDetails(result)}
|
||||
{#if safeUrl}
|
||||
<HoverCard.Root openDelay={150} closeDelay={100}>
|
||||
<HoverCard.Trigger
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:bg-muted/80 focus-visible:ring-ring inline-flex max-w-full items-center gap-1.5 rounded-full border bg-muted px-2.5 py-1 text-xs transition-colors outline-none focus-visible:ring-2"
|
||||
>
|
||||
{#if faviconUrl}
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
class="h-3 w-3 shrink-0 rounded-sm"
|
||||
onerror={hideBrokenIcon}
|
||||
/>
|
||||
{:else}
|
||||
<Globe class="text-muted-foreground/70 h-3 w-3 shrink-0" />
|
||||
{/if}
|
||||
<span class="truncate font-medium text-foreground/80">{result.title}</span>
|
||||
</HoverCard.Trigger>
|
||||
{#if showHoverCard}
|
||||
{@const publishDate = formatPublishDate(result.published)}
|
||||
{@const host = hostFor(safeUrl)}
|
||||
<HoverCard.Content
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
class="bg-popover text-popover-foreground z-50 w-80 max-w-[90vw] rounded-lg border p-0 shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-3">
|
||||
<a
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="line-clamp-3 text-sm font-medium leading-snug hover:underline"
|
||||
>{result.title}</a
|
||||
>
|
||||
{#if publishDate || result.author}
|
||||
<div class="text-muted-foreground flex items-center gap-1.5 text-[11px]">
|
||||
{#if publishDate}
|
||||
<span>{publishDate}</span>
|
||||
{/if}
|
||||
{#if publishDate && result.author}
|
||||
<span class="opacity-50">·</span>
|
||||
{/if}
|
||||
{#if result.author}
|
||||
<span class="truncate">{result.author}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if result.highlights}
|
||||
<p
|
||||
class="text-popover-foreground/85 line-clamp-5 text-xs leading-relaxed whitespace-pre-line"
|
||||
>
|
||||
{result.highlights}
|
||||
</p>
|
||||
{/if}
|
||||
{#if host}
|
||||
<div class="text-muted-foreground/80 truncate text-[11px]">{host}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</HoverCard.Content>
|
||||
{/if}
|
||||
</HoverCard.Root>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<CollapsibleContentBlock {open} class="my-2" {icon} {iconClass} {iconUrl} {title} {onToggle}>
|
||||
{#if results.length > 0}
|
||||
<div class="flex flex-wrap items-center gap-2 pb-1">
|
||||
{#each results as result (result.url)}
|
||||
{@render pill(result)}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if showSpinner}
|
||||
<div class="text-muted-foreground/70 flex items-center gap-2 py-1 text-xs italic">
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
<span>Searching...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-muted-foreground/70 py-1 text-xs italic">No results</div>
|
||||
{/if}
|
||||
</CollapsibleContentBlock>
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { XCircle } from '@lucide/svelte';
|
||||
import { SyntaxHighlightedCode } from '$lib/components/app';
|
||||
import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
|
||||
import { type AgenticSection } from '$lib/utils';
|
||||
import { parseWriteFileMeta } from './parsers/write-file';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
open: boolean;
|
||||
isStreaming: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
|
||||
const writeFileMeta = $derived(parseWriteFileMeta(section));
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={writeFileMeta} {onToggle}>
|
||||
{#snippet titleSnippet()}
|
||||
<span class="text-muted-foreground">Write file </span>
|
||||
<span class="font-mono">{writeFileMeta?.filePath}</span>
|
||||
{#if writeFileMeta?.errorMessage}
|
||||
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet children(meta, ctx)}
|
||||
{#if meta?.errorMessage}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
|
||||
>
|
||||
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
|
||||
<span>{meta.errorMessage}</span>
|
||||
</div>
|
||||
{:else if meta}
|
||||
<SyntaxHighlightedCode
|
||||
code={meta.content}
|
||||
language={meta.language}
|
||||
maxHeight={MAX_HEIGHT_CODE_BLOCK}
|
||||
streaming={ctx.isCodeStreaming}
|
||||
/>
|
||||
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
|
||||
{#if meta.resultMessage}
|
||||
{meta.resultMessage}{meta.bytesWritten != null ? RESULT_STAT_SEPARATOR : ''}{/if}
|
||||
{#if meta.bytesWritten != null}
|
||||
<span class="font-mono">{meta.bytesWritten}</span>
|
||||
bytes
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ToolCallBlock>
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
<script lang="ts" generics="TMeta">
|
||||
// Generic chrome shell shared by every per-tool block under
|
||||
// `ChatMessageToolCall/`. Owns:
|
||||
// - the collapsible wrapper (defaults to CollapsibleContentBlock;
|
||||
// `exec_shell_command` swaps in CollapsibleTerminalBlock via the
|
||||
// `wrapper` prop);
|
||||
// - the icon, spinner state, and MCP favicon fallback chain;
|
||||
// - the status subtitle pill.
|
||||
// Components supply only their `meta`, a title snippet, and a body
|
||||
// snippet - everything around them is this single source of truth.
|
||||
|
||||
import { Loader2, Wrench } from '@lucide/svelte';
|
||||
import { CollapsibleContentBlock } from '$lib/components/app';
|
||||
import { ICON_CLASS_DEFAULT, ICON_CLASS_SPIN } from '$lib/constants/css-classes';
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import { getBuiltinToolUi } from '$lib/constants/built-in-tools';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import type { AgenticSection, BuiltinToolUiEntry } from '$lib/utils';
|
||||
|
||||
type ToolCallBlockMetaWithError = TMeta & { errorMessage?: string };
|
||||
|
||||
interface ToolCallCtx {
|
||||
isStreaming: boolean;
|
||||
isPending: boolean;
|
||||
isStreamingCall: boolean;
|
||||
isCodeStreaming: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
section: AgenticSection;
|
||||
open: boolean;
|
||||
isStreaming: boolean;
|
||||
/**
|
||||
* The per-tool meta, including any `errorMessage` field that the
|
||||
* shared chrome uses to compute the status pill subtitle.
|
||||
*/
|
||||
meta: ToolCallBlockMetaWithError | null | undefined;
|
||||
/**
|
||||
* True while the tool's process is actively producing output
|
||||
* chunks after its args finished streaming (used by
|
||||
* `exec_shell_command`'s stdout feed).
|
||||
*/
|
||||
extraLiveStreaming?: boolean;
|
||||
/**
|
||||
* Swap the title-row icon for a spinning `Loader2` while the
|
||||
* spinner is showing. Only meaningful for tools where "live"
|
||||
* is interesting (e.g. exec_shell_command showing the in-flight
|
||||
* process). Other tools leave it off and render the spinner
|
||||
* inline within the body.
|
||||
*/
|
||||
spinIconWhenActive?: boolean;
|
||||
/**
|
||||
* Wrapper component that renders the title row and the body
|
||||
* children. Defaults to CollapsibleContentBlock;
|
||||
* `exec_shell_command` uses CollapsibleTerminalBlock for its
|
||||
* terminal-style frame.
|
||||
*/
|
||||
wrapper?: typeof CollapsibleContentBlock;
|
||||
title?: string;
|
||||
titleSnippet?: Snippet;
|
||||
onToggle?: () => void;
|
||||
children: Snippet<[TMeta | null | undefined, ToolCallCtx]>;
|
||||
}
|
||||
|
||||
let {
|
||||
section,
|
||||
open,
|
||||
isStreaming,
|
||||
meta,
|
||||
extraLiveStreaming = false,
|
||||
spinIconWhenActive = false,
|
||||
wrapper: Wrapper = CollapsibleContentBlock,
|
||||
title,
|
||||
titleSnippet,
|
||||
onToggle,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
|
||||
const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
|
||||
const showSpinner = $derived(isPending || (isStreamingCall && isStreaming) || extraLiveStreaming);
|
||||
const isCodeStreaming = $derived(isStreaming && (isPending || isStreamingCall));
|
||||
|
||||
const toolUi: BuiltinToolUiEntry | null = $derived(getBuiltinToolUi(section.toolName));
|
||||
const toolIcon: Component = $derived(
|
||||
spinIconWhenActive && showSpinner ? Loader2 : (toolUi?.icon ?? Wrench)
|
||||
);
|
||||
const toolIconClass = $derived(
|
||||
spinIconWhenActive && showSpinner ? ICON_CLASS_SPIN : ICON_CLASS_DEFAULT
|
||||
);
|
||||
// Drop the MCP favicon while the spinner is on so the title row
|
||||
// signals "in flight" without being overwritten by server branding.
|
||||
const mcpServerFavicon = $derived(
|
||||
showSpinner ? null : mcpStore.getServerFaviconForTool(section.toolName)
|
||||
);
|
||||
const iconUrl = $derived(
|
||||
showSpinner || (toolUi?.icon ?? null) || !mcpServerFavicon ? null : mcpServerFavicon
|
||||
);
|
||||
|
||||
function subtitleFor(errorMessage?: string): string | undefined {
|
||||
if (extraLiveStreaming) return 'streaming...';
|
||||
if (showSpinner) return 'executing...';
|
||||
if (errorMessage) return 'failed';
|
||||
if (isStreamingCall && !isStreaming) return 'incomplete';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const subtitle = $derived(subtitleFor(meta?.errorMessage));
|
||||
</script>
|
||||
|
||||
<Wrapper
|
||||
{open}
|
||||
class="my-2"
|
||||
icon={toolIcon}
|
||||
iconClass={toolIconClass}
|
||||
{iconUrl}
|
||||
{title}
|
||||
{titleSnippet}
|
||||
{subtitle}
|
||||
{onToggle}
|
||||
>
|
||||
{@render children(meta, {
|
||||
isStreaming,
|
||||
isPending,
|
||||
isStreamingCall,
|
||||
isCodeStreaming
|
||||
})}
|
||||
</Wrapper>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
// Helpers shared by the per-tool meta parsers under
|
||||
// `src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/`.
|
||||
// Each tool needs the same first three steps (tool-name check,
|
||||
// args-present check, JSON parse) - keeping them here lets each parser
|
||||
// stay focused on its own format quirks.
|
||||
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
|
||||
import type { AgenticSection } from '$lib/utils/agentic';
|
||||
|
||||
/**
|
||||
* Strict (final-state) JSON parser for a tool-args blob. Mirrors the
|
||||
* behaviour the per-tool components used before extraction: an
|
||||
* invalid JSON blob, a JSON array, or a JSON primitive all map to
|
||||
* `null` so callers don't have to guard against surprise shapes.
|
||||
*/
|
||||
function parseFinalToolArgs(blob: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(blob);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a section's toolArgs against an expected tool name. Returns
|
||||
* `null` when:
|
||||
* - the section's toolName doesn't match (component isn't for this
|
||||
* tool);
|
||||
* - the section has no args yet (call hasn't started streaming);
|
||||
* - or the args blob can't be parsed.
|
||||
*
|
||||
* Pass `{ partial: true }` for tools that need to render incrementally
|
||||
* as each token lands (read_file, edit_file, write_file).
|
||||
*/
|
||||
export function parseToolArgs(
|
||||
expected: BuiltInTool,
|
||||
section: AgenticSection,
|
||||
options: { partial?: boolean } = {}
|
||||
): Record<string, unknown> | null {
|
||||
if (section.toolName !== expected || !section.toolArgs) return null;
|
||||
return options.partial
|
||||
? parsePartialJsonArgs(section.toolArgs)
|
||||
: parseFinalToolArgs(section.toolArgs);
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
// Meta parser for `edit_file` tool calls. Reads the file path and the
|
||||
// array of edits from the streamed args (partial JSON for incremental
|
||||
// rendering), plus the result blob for `result` / `edits_applied` /
|
||||
// `error` fields.
|
||||
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import { FILE_PATH_SEPARATOR_REGEX } from '$lib/constants';
|
||||
import { tryParseToolResultObject, type AgenticSection } from '$lib/utils';
|
||||
import { parseToolArgs } from './_shared';
|
||||
|
||||
export type EditFileEdit = {
|
||||
oldText: string;
|
||||
newText: string;
|
||||
};
|
||||
|
||||
export type EditFileMeta = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
edits: EditFileEdit[];
|
||||
resultMessage?: string;
|
||||
editsApplied?: number;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export function parseEditFileMeta(section: AgenticSection): EditFileMeta | null {
|
||||
const args = parseToolArgs(BuiltInTool.EDIT_FILE, section, { partial: true });
|
||||
if (!args) return null;
|
||||
|
||||
const rawPath = args.path ?? args.file_path ?? args.filePath;
|
||||
if (typeof rawPath !== 'string' || !rawPath) return null;
|
||||
|
||||
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
|
||||
|
||||
// Filter the streamed edits array strictly: each entry must be an
|
||||
// object with a non-empty `old_text`. Edits without an old_text
|
||||
// would diff against empty and render as a full re-write.
|
||||
const rawEdits = Array.isArray(args.edits) ? args.edits : [];
|
||||
const edits: EditFileEdit[] = [];
|
||||
for (const e of rawEdits) {
|
||||
if (!e || typeof e !== 'object' || Array.isArray(e)) continue;
|
||||
const obj = e as Record<string, unknown>;
|
||||
const oldText = typeof obj.old_text === 'string' ? obj.old_text : '';
|
||||
if (!oldText) continue;
|
||||
const newText = typeof obj.new_text === 'string' ? obj.new_text : '';
|
||||
edits.push({ oldText, newText });
|
||||
}
|
||||
|
||||
const resultObj = tryParseToolResultObject(section.toolResult);
|
||||
let resultMessage: string | undefined;
|
||||
let editsApplied: number | undefined;
|
||||
let errorMessage: string | undefined;
|
||||
if (typeof resultObj?.error === 'string') {
|
||||
errorMessage = resultObj.error;
|
||||
} else if (resultObj) {
|
||||
if (typeof resultObj.result === 'string') {
|
||||
resultMessage = resultObj.result;
|
||||
}
|
||||
if (Number.isFinite(Number(resultObj.edits_applied))) {
|
||||
editsApplied = Number(resultObj.edits_applied);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fileName,
|
||||
filePath: rawPath,
|
||||
edits,
|
||||
resultMessage,
|
||||
editsApplied,
|
||||
errorMessage
|
||||
};
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
// Meta parser for `exec_shell_command` tool calls. Surfaces the
|
||||
// command text from args `command` / `cmd` / `shell_command` aliases.
|
||||
// The exit-status and error parsing live in their own utilities
|
||||
// (`parse-exec-shell-status.ts` / `parse-exec-shell-error.ts`) - this
|
||||
// file only deals with what's strictly about *calling* the tool, since
|
||||
// the error / exit status elide from call-section to result-section.
|
||||
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/utils';
|
||||
import { parseToolArgs } from './_shared';
|
||||
|
||||
export type ExecShellCommandMeta = {
|
||||
command: string;
|
||||
};
|
||||
|
||||
export function parseExecShellCommandMeta(section: AgenticSection): ExecShellCommandMeta | null {
|
||||
const args = parseToolArgs(BuiltInTool.EXEC_SHELL_COMMAND, section);
|
||||
if (!args) return null;
|
||||
|
||||
const commandRaw = args.command ?? args.cmd ?? args.shell_command;
|
||||
if (typeof commandRaw !== 'string' || !commandRaw) return null;
|
||||
return { command: commandRaw };
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
// Meta parser for `file_glob_search` tool calls. Reads the path,
|
||||
// include pattern, and optional exclude from the args (strict parsing)
|
||||
// and the matches from the result blob. Like grep_search, the result
|
||||
// parser keeps the original raw-text fallback for MCP servers that
|
||||
// emit unparseable output.
|
||||
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import { splitSearchSummaryList, type AgenticSection } from '$lib/utils';
|
||||
import { parseToolArgs } from './_shared';
|
||||
|
||||
export type FileGlobSearchMeta = {
|
||||
path: string;
|
||||
include: string;
|
||||
exclude?: string;
|
||||
matches: string[];
|
||||
totalMatches?: number;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export function parseFileGlobSearchMeta(section: AgenticSection): FileGlobSearchMeta | null {
|
||||
const args = parseToolArgs(BuiltInTool.FILE_GLOB_SEARCH, section);
|
||||
if (!args) return null;
|
||||
|
||||
const path = typeof args.path === 'string' ? args.path : '';
|
||||
const include = typeof args.include === 'string' && args.include ? args.include : '**';
|
||||
const exclude = typeof args.exclude === 'string' && args.exclude ? args.exclude : undefined;
|
||||
if (!path) return null;
|
||||
|
||||
let matches: string[] = [];
|
||||
let totalMatches: number | undefined;
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
const toolResultString = section.toolResult;
|
||||
if (toolResultString) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(toolResultString);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.error === 'string') {
|
||||
errorMessage = obj.error;
|
||||
} else if (typeof obj.plain_text_response === 'string') {
|
||||
const split = splitSearchSummaryList(obj.plain_text_response, (total) => {
|
||||
totalMatches = total;
|
||||
});
|
||||
matches = split.lines;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// See grep-search.ts: same fallback used there.
|
||||
const split = splitSearchSummaryList(toolResultString, (total) => {
|
||||
totalMatches = total;
|
||||
});
|
||||
matches = split.lines;
|
||||
}
|
||||
}
|
||||
|
||||
return { path, include, exclude, matches, totalMatches, errorMessage };
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
// Meta parser for `grep_search` tool calls. Reads the path/pattern
|
||||
// triplet from args (strict parsing - we wait for the args to
|
||||
// complete) and the matches from the result blob. The result parser
|
||||
// keeps the original "scan result as raw text on JSON.parse failure"
|
||||
// fallback so MCP servers that return unparseable output still get
|
||||
// surfaced.
|
||||
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import { splitSearchSummaryList, type AgenticSection } from '$lib/utils';
|
||||
import { parseToolArgs } from './_shared';
|
||||
|
||||
export type GrepSearchMatch = {
|
||||
file: string;
|
||||
line?: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type GrepSearchMeta = {
|
||||
path: string;
|
||||
pattern: string;
|
||||
include: string;
|
||||
exclude?: string;
|
||||
showLineNumbers: boolean;
|
||||
matches: GrepSearchMatch[];
|
||||
totalMatches?: number;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export function parseGrepSearchMeta(section: AgenticSection): GrepSearchMeta | null {
|
||||
const args = parseToolArgs(BuiltInTool.GREP_SEARCH, section);
|
||||
if (!args) return null;
|
||||
|
||||
const path = typeof args.path === 'string' ? args.path : '';
|
||||
const pattern = typeof args.pattern === 'string' ? args.pattern : '';
|
||||
if (!path || !pattern) return null;
|
||||
|
||||
const include = typeof args.include === 'string' && args.include ? args.include : '**';
|
||||
const exclude = typeof args.exclude === 'string' && args.exclude ? args.exclude : undefined;
|
||||
const showLineNumbers = args.return_line_numbers === true;
|
||||
|
||||
let matches: GrepSearchMatch[] = [];
|
||||
let totalMatches: number | undefined;
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
const toolResultString = section.toolResult;
|
||||
if (toolResultString) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(toolResultString);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.error === 'string') {
|
||||
errorMessage = obj.error;
|
||||
} else if (typeof obj.plain_text_response === 'string') {
|
||||
const split = splitSearchSummaryList(obj.plain_text_response, (total) => {
|
||||
totalMatches = total;
|
||||
});
|
||||
matches = split.lines.map((line) => parseGrepLine(line, showLineNumbers));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Result wasn't JSON: keep behaviour for MCP servers that
|
||||
// emit raw text and treat each line as a `<file>:<content>`
|
||||
// (or `<file>:<line>:<content>`) match.
|
||||
const split = splitSearchSummaryList(toolResultString, (total) => {
|
||||
totalMatches = total;
|
||||
});
|
||||
matches = split.lines.map((line) => parseGrepLine(line, showLineNumbers));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
pattern,
|
||||
include,
|
||||
exclude,
|
||||
showLineNumbers,
|
||||
matches,
|
||||
totalMatches,
|
||||
errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
function parseGrepLine(line: string, showLineNumbers: boolean): GrepSearchMatch {
|
||||
// Server output:
|
||||
// <file>:<content> when return_line_numbers=false
|
||||
// <file>:<lineno>:<content> when return_line_numbers=true
|
||||
const firstColon = line.indexOf(':');
|
||||
if (firstColon === -1) {
|
||||
return { file: line, content: '' };
|
||||
}
|
||||
const file = line.slice(0, firstColon);
|
||||
const tail = line.slice(firstColon + 1);
|
||||
|
||||
if (!showLineNumbers) {
|
||||
return { file, content: tail };
|
||||
}
|
||||
|
||||
const secondColon = tail.indexOf(':');
|
||||
if (secondColon === -1) {
|
||||
return { file, content: tail };
|
||||
}
|
||||
const lineNum = parseInt(tail.slice(0, secondColon), 10);
|
||||
return {
|
||||
file,
|
||||
line: Number.isFinite(lineNum) ? lineNum : undefined,
|
||||
content: tail.slice(secondColon + 1)
|
||||
};
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
// Meta parser for `read_file` tool calls. Reads the file path and an
|
||||
// optional line range (either `start_line`+`end_line` or
|
||||
// `start_line`+`line_count`). Args are parsed partially so a header
|
||||
// can render incrementally as the file path streams in.
|
||||
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
FILE_PATH_SEPARATOR_REGEX,
|
||||
TEXT_LANGUAGE_PREFIX_REGEX
|
||||
} from '$lib/constants';
|
||||
import { getFileTypeByExtension, type AgenticSection } from '$lib/utils';
|
||||
import { parseToolArgs } from './_shared';
|
||||
|
||||
export type ReadFileMeta = {
|
||||
fileName: string;
|
||||
lineRange: { start: number; end: number } | null;
|
||||
language: string;
|
||||
};
|
||||
|
||||
export function parseReadFileMeta(section: AgenticSection): ReadFileMeta | null {
|
||||
const args = parseToolArgs(BuiltInTool.READ_FILE, section, { partial: true });
|
||||
if (!args) return null;
|
||||
|
||||
const rawPath = args.path ?? args.file_path ?? args.filePath;
|
||||
if (typeof rawPath !== 'string' || !rawPath) return null;
|
||||
|
||||
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
|
||||
|
||||
// Models emit range arguments under several aliases. Accept all to
|
||||
// stay forgiving across prompt variations.
|
||||
const startRaw = args.start_line ?? args.line_start ?? args.startLine ?? args.from_line;
|
||||
const endRaw = args.end_line ?? args.line_end ?? args.endLine ?? args.to_line;
|
||||
const countRaw = args.line_count ?? args.count ?? args.num_lines;
|
||||
|
||||
let lineRange: { start: number; end: number } | null = null;
|
||||
const sNum = Number(startRaw);
|
||||
const eNum = Number(endRaw);
|
||||
if (startRaw != null && endRaw != null && Number.isFinite(sNum) && Number.isFinite(eNum)) {
|
||||
lineRange = { start: sNum, end: eNum };
|
||||
} else if (startRaw != null && countRaw != null) {
|
||||
const cNum = Number(countRaw);
|
||||
if (Number.isFinite(sNum) && Number.isFinite(cNum)) {
|
||||
lineRange = { start: sNum, end: sNum + cNum - 1 };
|
||||
}
|
||||
}
|
||||
|
||||
const fileType = getFileTypeByExtension(fileName);
|
||||
const language = fileType ? fileType.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') : DEFAULT_LANGUAGE;
|
||||
|
||||
return { fileName, lineRange, language };
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
// Meta parser for `run_javascript` tool calls. Reads the JS code and
|
||||
// optional timeout from args (strict parsing) and surfaces any error
|
||||
// from the result blob. SandboxService.formatReply emits a JSON object
|
||||
// containing an `error` field on failure, but a partial/non-JSON
|
||||
// failure renders as a flat line beginning with `Error:`. Both shapes
|
||||
// are handled.
|
||||
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/utils';
|
||||
import { parseToolArgs } from './_shared';
|
||||
|
||||
export type RunJavascriptMeta = {
|
||||
code: string;
|
||||
timeoutMs?: number;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export function parseRunJavascriptMeta(section: AgenticSection): RunJavascriptMeta | null {
|
||||
const args = parseToolArgs(BuiltInTool.RUN_JAVASCRIPT, section);
|
||||
if (!args) return null;
|
||||
|
||||
const code = typeof args.code === 'string' ? args.code : '';
|
||||
if (!code) return null;
|
||||
|
||||
const timeoutRaw = Number(args.timeout_ms);
|
||||
const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : undefined;
|
||||
|
||||
let errorMessage: string | undefined;
|
||||
const toolResultString = section.toolResult;
|
||||
if (toolResultString) {
|
||||
// Branches matter here: a JSON object can carry `error`, but a
|
||||
// JSON array always represents successful output (sandbox returns
|
||||
// the array of values). Only when the result isn't a JSON object
|
||||
// do we scan raw lines for the `Error:` prefix.
|
||||
let parsedObject: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(toolResultString);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
parsedObject = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
parsedObject = null;
|
||||
}
|
||||
if (typeof parsedObject?.error === 'string') {
|
||||
errorMessage = parsedObject.error;
|
||||
} else if (!parsedObject) {
|
||||
const errorLine = toolResultString
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith('Error:'));
|
||||
if (errorLine) errorMessage = errorLine.slice('Error:'.length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return { code, timeoutMs, errorMessage };
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
// Meta parser for `write_file` tool calls. Reads the path/content from
|
||||
// the streamed args (partial JSON so we can render before the call
|
||||
// finishes) and surfaces `bytes`, `result`, and `error` from the
|
||||
// result blob.
|
||||
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
FILE_PATH_SEPARATOR_REGEX,
|
||||
TEXT_LANGUAGE_PREFIX_REGEX
|
||||
} from '$lib/constants';
|
||||
import { getFileTypeByExtension, tryParseToolResultObject, type AgenticSection } from '$lib/utils';
|
||||
import { parseToolArgs } from './_shared';
|
||||
|
||||
export type WriteFileMeta = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
language: string;
|
||||
content: string;
|
||||
bytesWritten?: number;
|
||||
resultMessage?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export function parseWriteFileMeta(section: AgenticSection): WriteFileMeta | null {
|
||||
const args = parseToolArgs(BuiltInTool.WRITE_FILE, section, { partial: true });
|
||||
if (!args) return null;
|
||||
|
||||
// Tool contracts drifted over time: some models emit `path`,
|
||||
// others `file_path` / `filePath`. Accept all three.
|
||||
const rawPath = args.path ?? args.file_path ?? args.filePath;
|
||||
if (typeof rawPath !== 'string' || !rawPath) return null;
|
||||
|
||||
const fileName = rawPath.split(FILE_PATH_SEPARATOR_REGEX).pop() || rawPath;
|
||||
const content = typeof args.content === 'string' ? args.content : '';
|
||||
const language =
|
||||
getFileTypeByExtension(rawPath)?.replace(TEXT_LANGUAGE_PREFIX_REGEX, '') ?? DEFAULT_LANGUAGE;
|
||||
|
||||
const resultObj = tryParseToolResultObject(section.toolResult);
|
||||
const bytesWritten =
|
||||
resultObj && Number.isFinite(Number(resultObj.bytes)) ? Number(resultObj.bytes) : undefined;
|
||||
const resultMessage = typeof resultObj?.result === 'string' ? resultObj.result : undefined;
|
||||
const errorMessage = typeof resultObj?.error === 'string' ? resultObj.error : undefined;
|
||||
|
||||
return {
|
||||
fileName,
|
||||
filePath: rawPath,
|
||||
language,
|
||||
content,
|
||||
bytesWritten,
|
||||
resultMessage,
|
||||
errorMessage
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user