ui: CWD for agent (#26518)
* server : extend file_glob_search for UI pickers * ui : add per-conversation working directory with picker * ui : add path navigation and search scope to cwd picker Treat path-like queries (starting with / or ~) as directory navigation instead of glob-matching the whole query: search the parent for the last segment, and descend into an exactly-typed directory by listing its children. Show the effective search scope in the footer and auto-search on open so the current directory and its siblings appear immediately. Assisted-by: Claude * db : persist per-call tool cwd on tool result messages * ui : abbreviate tool paths under home with a tilde * ui : show the per-call cwd on exec shell rows * ui : clarify the synthetic cwd message for the model * ui : reuse the trailing cwd row on a repeated pick * ui : don't jump when a cwd row is injected mid-chat * chore: Formatting * refactor: Cleanup comments * ui : unify working directory naming and add a synthetic-message flag * ui : render synthetic cwd rows without a scroll jump * ui : decouple the working directory picker into utils and sub-components * ui : add get_info tool call block * chore: Formatting * refactor: Cleanup * refactor: Cleanup * refactor: Cleanup * fix: UI * server : harden file_glob_search listing (kind enum, timeout, symlink guard, absolute base) * ui : use persisted isSynthetic flag for cwd rows, drop legacy formats * ui : cache picker search, fail visibly on native resolve * ui : escape glob metacharacters in picker search glob * ui : simplify auto-scroll pin * chore: Format * fix: Use `SvelteMap` * refactor: Post-review fixes * ui: accept Windows roots in the working directory picker recognize a drive root (C:) and a UNC share (//host/share) as path navigation, alongside the POSIX root and ~, so a query like D:\repos lists that directory instead of glob-matching it under the home dir split below the root, so a bare drive resolves to its root rather than to a drive-relative prefix rewrite backslashes into forward slashes only when the query carries a Windows root, since a backslash is a legal POSIX filename character paths keep travelling with forward slashes, which is what the server returns and what Windows accepts --------- Co-authored-by: Pascal <admin@serveurperso.com>
This commit is contained in:
co-authored by
Pascal
parent
0713275082
commit
2f56fc3431
@@ -6,6 +6,7 @@
|
||||
ChatFormMcpResourcesList,
|
||||
ChatFormPickers,
|
||||
ChatFormTextarea,
|
||||
ChatFormWorkingDirectory,
|
||||
DialogMcpResourcesBrowser
|
||||
} from '$lib/components/app';
|
||||
import {
|
||||
@@ -31,7 +32,13 @@
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte';
|
||||
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import {
|
||||
conversationsStore,
|
||||
activeMessages,
|
||||
activeConversation,
|
||||
pendingCwd
|
||||
} from '$lib/stores/conversations.svelte';
|
||||
import type { GetPromptResult, MCPPromptInfo, MCPResourceInfo, PromptMessage } from '$lib/types';
|
||||
import { isIMEComposing, parseClipboardContent, uuid } from '$lib/utils';
|
||||
import {
|
||||
@@ -107,6 +114,15 @@
|
||||
let isInlineResourcePickerOpen = $state(false);
|
||||
let resourceSearchQuery = $state('');
|
||||
|
||||
let cwd = $derived(activeConversation()?.cwd ?? pendingCwd());
|
||||
|
||||
async function handleWorkingDirectoryChange(value: string | null) {
|
||||
await conversationsStore.setCwd(value);
|
||||
if (conversationsStore.activeConversation) {
|
||||
await chatStore.recordCwdChange(value?.trim() || null);
|
||||
}
|
||||
}
|
||||
|
||||
// Resource Dialog State
|
||||
let isResourceDialogOpen = $state(false);
|
||||
let preSelectedResourceUri = $state<string | undefined>(undefined);
|
||||
@@ -155,6 +171,12 @@
|
||||
audioRecorder = new AudioRecorder();
|
||||
});
|
||||
|
||||
// Defer so the closing popover's focus scope tears down first - bits-ui
|
||||
// yanks a synchronous focus() back into the still-mounted popover.
|
||||
function refocusInput() {
|
||||
queueMicrotask(() => textareaRef?.focus());
|
||||
}
|
||||
|
||||
export function focus() {
|
||||
textareaRef?.focus();
|
||||
}
|
||||
@@ -470,7 +492,7 @@
|
||||
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
|
||||
|
||||
<form
|
||||
class="relative {className}"
|
||||
class="relative grid {className}"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -559,6 +581,15 @@
|
||||
</div>
|
||||
|
||||
<ContextGaugePopup />
|
||||
|
||||
{#if toolsStore.builtinTools.length > 0}
|
||||
<ChatFormWorkingDirectory
|
||||
directory={cwd}
|
||||
onChange={handleWorkingDirectoryChange}
|
||||
onClose={refocusInput}
|
||||
{disabled}
|
||||
/>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<DialogMcpResourcesBrowser
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
<script lang="ts">
|
||||
import { FolderOpen } from '@lucide/svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
|
||||
import {
|
||||
abbreviateHome,
|
||||
buildCaseInsensitiveGlob,
|
||||
joinPath,
|
||||
lastPathSegment,
|
||||
rankEntries,
|
||||
splitPathQuery,
|
||||
type GlobEntry
|
||||
} from '$lib/utils';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
|
||||
import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte';
|
||||
import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.svelte';
|
||||
import {
|
||||
DEFAULT_MOBILE_BREAKPOINT,
|
||||
GLOB_WILDCARD,
|
||||
HOME_TILDE,
|
||||
MAX_RESULTS_SHOWN,
|
||||
NATIVE_LIMIT,
|
||||
NATIVE_MAX_DEPTH,
|
||||
PATH_NAV_MAX_DEPTH,
|
||||
SEARCH_DEBOUNCE_MS,
|
||||
SEARCH_LIMIT,
|
||||
SEARCH_MAX_DEPTH
|
||||
} from '$lib/constants';
|
||||
|
||||
// Microtask delay so the popover's focus scope tears down first.
|
||||
const FOCUS_DELAY_MS = 0;
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
directory?: string | null;
|
||||
onChange?: (directory: string | null) => void;
|
||||
/**
|
||||
* Lets the host refocus the chat input so typing can resume without
|
||||
* an extra click after the popover closes.
|
||||
*/
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
directory = $bindable(null),
|
||||
onChange,
|
||||
onClose
|
||||
}: Props = $props();
|
||||
|
||||
// File System Access API is opt-in: when available (Chrome / Edge / Opera) the popover
|
||||
// exposes a "Browse" button that opens the native folder picker. When unavailable the
|
||||
// popover still works via the text input - no alerts, no upload semantics.
|
||||
const pickerSupported =
|
||||
typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function';
|
||||
|
||||
// Popover open state; the element handles outside-click and Escape.
|
||||
let isOpen = $state(false);
|
||||
let inputValue = $state('');
|
||||
let searchInputRef: HTMLInputElement | null = $state(null);
|
||||
|
||||
let queryResults = $state<string[]>([]);
|
||||
let isSearching = $state(false);
|
||||
let searchError = $state<string | null>(null);
|
||||
let hoveredIndex = $state(-1);
|
||||
// Bumped only by ArrowUp/ArrowDown handlers; the list scrolls the
|
||||
// highlighted row into view only via this trigger, never on hover.
|
||||
let scrollTrigger = $state(0);
|
||||
let listContainer = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// Absolute home directory on the server, resolved once per session by
|
||||
// the tools store. Anchors both the search scope and the chip's `~`
|
||||
// abbreviation.
|
||||
let homeBase = $derived(toolsStore.serverHome);
|
||||
|
||||
// AbortController + sequence counter to discard stale responses when the user
|
||||
// keeps typing; a newer call aborts the previous one. The sequence counter
|
||||
// also covers the gap between abort and the catch handler.
|
||||
let searchController: AbortController | null = null;
|
||||
let searchSeq = 0;
|
||||
|
||||
// Cache of the last file_glob_search result per (parent, include, max_depth),
|
||||
// so repeated queries in the same directory don't re-walk the tree. Entries
|
||||
// expire after a short TTL.
|
||||
const SEARCH_CACHE_TTL_MS = 2000;
|
||||
const searchCache = new SvelteMap<string, { results: GlobEntry[]; base: string; at: number }>();
|
||||
|
||||
const runSearch = debounce((query: string) => {
|
||||
void doSearch(query);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
// Resolve home eagerly on mount so the chip can abbreviate before the
|
||||
// user opens the picker. resolveServerHome() is cached, so repeat calls
|
||||
// (e.g. from handleOpenChange) are no-ops.
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
void toolsStore.resolveServerHome();
|
||||
});
|
||||
|
||||
// Auto-focus the search input when the popover opens.
|
||||
// HTML `autofocus` is unreliable on dynamically shown elements, so we
|
||||
// use a microtask (0ms setTimeout) after the effect flushes.
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
|
||||
});
|
||||
|
||||
let lastScrollTrigger: number | null = null;
|
||||
|
||||
// hoveredIndex/queryResults are untracked so hover and result replacement
|
||||
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger
|
||||
$effect(() => {
|
||||
if (scrollTrigger === lastScrollTrigger) return;
|
||||
lastScrollTrigger = scrollTrigger;
|
||||
untrack(() => {
|
||||
if (!listContainer) return;
|
||||
if (hoveredIndex < 0 || hoveredIndex >= queryResults.length) return;
|
||||
const selectedElement = listContainer.querySelector(
|
||||
`[data-result-index="${hoveredIndex}"]`
|
||||
) as HTMLElement | null;
|
||||
selectedElement?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
});
|
||||
});
|
||||
|
||||
function cancelSearch() {
|
||||
searchController?.abort();
|
||||
searchSeq++;
|
||||
isSearching = false;
|
||||
}
|
||||
|
||||
// Effective directory the current search runs against (shown in the
|
||||
// footer); updated by doSearch, including when an exactly-typed
|
||||
// directory is "entered".
|
||||
let searchScope = $state(HOME_TILDE);
|
||||
|
||||
// Runs a directory listing through the cache, so a repeated query in the
|
||||
// same directory does not re-walk the tree on the server.
|
||||
async function searchDirs(
|
||||
path: string,
|
||||
include: string,
|
||||
maxDepth: number,
|
||||
signal: AbortSignal
|
||||
): Promise<{ base: string; entries: GlobEntry[]; error?: string }> {
|
||||
const key = `${path}\u0000${include}\u0000${maxDepth}`;
|
||||
const cached = searchCache.get(key);
|
||||
if (cached && Date.now() - cached.at < SEARCH_CACHE_TTL_MS) {
|
||||
return { base: cached.base, entries: cached.results };
|
||||
}
|
||||
const res = await ToolsService.executeToolRaw(
|
||||
BuiltInTool.FILE_GLOB_SEARCH,
|
||||
{ path, type: GlobSearchType.DIR, include, max_depth: maxDepth, limit: SEARCH_LIMIT },
|
||||
signal
|
||||
);
|
||||
if (typeof res.error === 'string') return { base: '', entries: [], error: res.error };
|
||||
const base = typeof res.base === 'string' ? res.base : '';
|
||||
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
|
||||
searchCache.set(key, { results: entries, base, at: Date.now() });
|
||||
return { base, entries };
|
||||
}
|
||||
|
||||
async function doSearch(query: string) {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
queryResults = [];
|
||||
searchError = null;
|
||||
isSearching = false;
|
||||
hoveredIndex = -1;
|
||||
searchScope = homeBase ?? HOME_TILDE;
|
||||
return;
|
||||
}
|
||||
|
||||
cancelSearch();
|
||||
const controller = new AbortController();
|
||||
searchController = controller;
|
||||
const mySeq = ++searchSeq;
|
||||
|
||||
const pathQuery = splitPathQuery(trimmed);
|
||||
|
||||
isSearching = true;
|
||||
try {
|
||||
// A generous limit is requested because ranking happens
|
||||
// client-side; only the top 20 are shown.
|
||||
const searchPath = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
|
||||
const include = pathQuery
|
||||
? pathQuery.last
|
||||
? buildCaseInsensitiveGlob(pathQuery.last)
|
||||
: GLOB_WILDCARD
|
||||
: buildCaseInsensitiveGlob(trimmed);
|
||||
const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : SEARCH_MAX_DEPTH;
|
||||
const res = await searchDirs(searchPath, include, maxDepth, controller.signal);
|
||||
if (mySeq !== searchSeq) return;
|
||||
if (res.error) {
|
||||
queryResults = [];
|
||||
hoveredIndex = -1;
|
||||
searchError = res.error;
|
||||
return;
|
||||
}
|
||||
const { base, entries } = res;
|
||||
const ranked = rankEntries(entries, pathQuery?.last ?? trimmed);
|
||||
let results = ranked.map((e) => joinPath(base, e.path));
|
||||
searchScope = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
|
||||
|
||||
// An exactly-typed directory is "entered": list its children too,
|
||||
// so path navigation doesn't require a trailing slash.
|
||||
const last = pathQuery?.last;
|
||||
const exact = last
|
||||
? ranked.find((e) => lastPathSegment(e.path).toLowerCase() === last.toLowerCase())
|
||||
: undefined;
|
||||
if (exact) {
|
||||
const exactDir = joinPath(base, exact.path);
|
||||
const childRes = await searchDirs(
|
||||
exactDir,
|
||||
GLOB_WILDCARD,
|
||||
PATH_NAV_MAX_DEPTH,
|
||||
controller.signal
|
||||
);
|
||||
if (mySeq !== searchSeq) return;
|
||||
if (!childRes.error) {
|
||||
const children = childRes.entries
|
||||
.map((e) => joinPath(childRes.base, e.path))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
results = [...results, ...children];
|
||||
searchScope = exactDir;
|
||||
}
|
||||
}
|
||||
|
||||
queryResults = results.slice(0, MAX_RESULTS_SHOWN);
|
||||
hoveredIndex = queryResults.length > 0 ? 0 : -1;
|
||||
// new results: scroll the list back to the top (first item is hovered)
|
||||
if (hoveredIndex === 0) scrollTrigger++;
|
||||
searchError = null;
|
||||
} catch (err) {
|
||||
if (mySeq !== searchSeq) return;
|
||||
queryResults = [];
|
||||
hoveredIndex = -1;
|
||||
if (controller.signal.aborted) return;
|
||||
searchError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
if (mySeq === searchSeq) isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Single funnel for every local close so the host refocus fires
|
||||
// regardless of which commit/dismiss path ended the interaction.
|
||||
function closePicker() {
|
||||
isOpen = false;
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function commit(path: string) {
|
||||
directory = path;
|
||||
onChange?.(path);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function setDirectory(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
directory = trimmed;
|
||||
onChange?.(trimmed);
|
||||
}
|
||||
|
||||
// Resolve a folder name picked via the browser-native picker (which exposes
|
||||
// only the leaf name) to a server-side absolute path. Returns null when the
|
||||
// server cannot locate a matching directory, so the caller can fail visibly
|
||||
// instead of committing a bare leaf name that would resolve against the
|
||||
// server process working directory.
|
||||
async function resolveNativeName(name: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
|
||||
path: homeBase ?? HOME_TILDE,
|
||||
type: GlobSearchType.DIR,
|
||||
include: buildCaseInsensitiveGlob(name),
|
||||
max_depth: NATIVE_MAX_DEPTH,
|
||||
limit: NATIVE_LIMIT
|
||||
});
|
||||
const base = typeof res.base === 'string' ? res.base : '';
|
||||
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
|
||||
const match = entries.find(
|
||||
(e) => lastPathSegment(e.path).toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
return match ? joinPath(base, match.path) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function browseNative() {
|
||||
if (disabled || !window.showDirectoryPicker) return;
|
||||
try {
|
||||
const handle = await window.showDirectoryPicker();
|
||||
const path = await resolveNativeName(handle.name);
|
||||
if (path) {
|
||||
setDirectory(path);
|
||||
closePicker();
|
||||
} else {
|
||||
// keep the previous cwd and fail visibly instead of committing a
|
||||
// bare leaf name that would resolve against the server cwd
|
||||
searchError = `Could not resolve "${handle.name}" to a server path`;
|
||||
}
|
||||
} catch (err) {
|
||||
// user cancelled - silently ignore; other errors are logged
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
console.error('[ChatFormWorkingDirectory] showDirectoryPicker failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const value = inputValue.trim();
|
||||
if (!value) {
|
||||
closePicker();
|
||||
return;
|
||||
}
|
||||
setDirectory(value);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
// Commit the highlighted result, falling back to the raw input
|
||||
// only when the query returned no matches.
|
||||
if (hoveredIndex >= 0 && queryResults[hoveredIndex]) {
|
||||
commit(queryResults[hoveredIndex]);
|
||||
} else if (queryResults.length === 0) {
|
||||
handleSubmit();
|
||||
}
|
||||
} else if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
if (queryResults.length > 0) {
|
||||
event.preventDefault();
|
||||
hoveredIndex = (hoveredIndex + 1) % queryResults.length;
|
||||
scrollTrigger++;
|
||||
}
|
||||
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||
if (queryResults.length > 0) {
|
||||
event.preventDefault();
|
||||
hoveredIndex = hoveredIndex <= 0 ? queryResults.length - 1 : hoveredIndex - 1;
|
||||
scrollTrigger++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputInput(value: string) {
|
||||
hoveredIndex = -1;
|
||||
if (value.trim().length > 0) {
|
||||
runSearch(value);
|
||||
}
|
||||
}
|
||||
|
||||
function clearDirectory(event?: MouseEvent) {
|
||||
// Stop the click from bubbling into the popover trigger and re-opening
|
||||
// the picker on top of the now-cleared state.
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
directory = null;
|
||||
onChange?.(null);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
// The chip is always visible; the X clears the directory (no-op when
|
||||
// already empty).
|
||||
function handleDismiss(event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
if (directory) {
|
||||
clearDirectory(event);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
isOpen = open;
|
||||
if (open) {
|
||||
// Seed the search field with the current path so the user can refine it
|
||||
// (or hit Enter to confirm / clear via the X icon).
|
||||
inputValue = directory ?? '';
|
||||
hoveredIndex = -1;
|
||||
queryResults = [];
|
||||
searchError = null;
|
||||
void toolsStore.resolveServerHome();
|
||||
searchScope = homeBase ?? HOME_TILDE;
|
||||
if (inputValue.trim()) runSearch(inputValue);
|
||||
} else {
|
||||
cancelSearch();
|
||||
// bits-ui-initiated close (Escape on the content, outside-click,
|
||||
// trigger toggle) - the only path that bypasses closePicker().
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
// Tooltips only on wider viewports - hover surfaces get in the way on
|
||||
// touch / narrow layouts. Mirrors the gate used in ActionIcon.
|
||||
let innerWidth = $state(0);
|
||||
const showTooltip = $derived(innerWidth > DEFAULT_MOBILE_BREAKPOINT);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'justify-self-start flex min-w-0 w-auto items-center gap-1 mt-1.5 py-1 px-2 backdrop-blur-2xl rounded-md',
|
||||
className,
|
||||
isOpen && 'w-full'
|
||||
]}
|
||||
>
|
||||
<Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Popover.Trigger {disabled} class="flex justify-start">
|
||||
<ChatFormWorkingDirectoryChip
|
||||
{directory}
|
||||
{homeBase}
|
||||
{disabled}
|
||||
{showTooltip}
|
||||
onClear={handleDismiss}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
class="md:max-w-3xl w-[calc(100vw-1rem)] rounded-xl border-border/50 p-0 shadow-xl md:-translate-2!"
|
||||
onkeydown={handleKeydown}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div class="p-2 min-h-28 flex flex-col justify-between">
|
||||
<SearchInput
|
||||
bind:ref={searchInputRef}
|
||||
bind:value={inputValue}
|
||||
placeholder="Choose working directory"
|
||||
onInput={handleInputInput}
|
||||
onClose={closePicker}
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
{#if inputValue.trim() && (isSearching || queryResults.length > 0 || searchError)}
|
||||
<ChatFormWorkingDirectoryResultsList
|
||||
results={queryResults}
|
||||
{hoveredIndex}
|
||||
{isSearching}
|
||||
error={searchError}
|
||||
rawQuery={inputValue}
|
||||
bind:container={listContainer}
|
||||
onCommit={commit}
|
||||
onHover={(index) => (hoveredIndex = index)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if pickerSupported}
|
||||
<button
|
||||
type="button"
|
||||
class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={browseNative}
|
||||
>
|
||||
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span>Browse</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if homeBase}
|
||||
<div class="-mx-2 my-1 h-px bg-border/20" aria-hidden="true"></div>
|
||||
|
||||
<span class="px-2 py-2 font-mono text-[10px]">
|
||||
Searching in:
|
||||
|
||||
<span class="truncate text-muted-foreground/70" title={searchScope}
|
||||
>{abbreviateHome(searchScope, homeBase)}</span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { Folder, X } from '@lucide/svelte';
|
||||
import { abbreviateWorkingDir } from '$lib/utils';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ActionIcon } from '$lib/components/app/actions';
|
||||
|
||||
interface Props {
|
||||
directory?: string | null;
|
||||
homeBase?: string | null;
|
||||
disabled?: boolean;
|
||||
showTooltip?: boolean;
|
||||
onClear?: (event?: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
directory = null,
|
||||
homeBase = null,
|
||||
disabled = false,
|
||||
showTooltip = false,
|
||||
onClear
|
||||
}: Props = $props();
|
||||
|
||||
const displayLabel = $derived(
|
||||
directory ? abbreviateWorkingDir(directory, homeBase) : 'Select working directory'
|
||||
);
|
||||
// Full path surface: hover the abbreviated label to recall the exact directory.
|
||||
const displayLabelTitle = $derived(directory ?? '');
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="text-muted-foreground inline-flex items-center gap-1 text-xs group"
|
||||
class:text-foreground={directory}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1 cursor-pointer">
|
||||
<Folder class="w-3.5 h-3.5" />
|
||||
|
||||
{#if showTooltip && displayLabelTitle}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<span {...props} class="max-w-64 truncate">{displayLabel}</span>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{displayLabelTitle}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
<span class="max-w-64 truncate">{displayLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if directory}
|
||||
<div
|
||||
class="w-0 overflow-hidden opacity-0 transition-[width,opacity] duration-200 ease-out group-hover:w-auto group-hover:opacity-100"
|
||||
>
|
||||
<ActionIcon
|
||||
icon={X}
|
||||
tooltip="Reset working directory"
|
||||
ariaLabel="Reset working directory"
|
||||
{disabled}
|
||||
onclick={onClear}
|
||||
iconSize="h-3 w-3"
|
||||
stopPropagationOnClick
|
||||
class="!h-4 !w-4 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { Folder } from '@lucide/svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { highlightMatch } from '$lib/utils';
|
||||
import { cn } from '$lib/components/ui/utils';
|
||||
|
||||
// Fly-in transition for the results list.
|
||||
const FLY_Y_PX = -4;
|
||||
const FLY_DURATION_MS = 100;
|
||||
|
||||
interface Props {
|
||||
results: string[];
|
||||
hoveredIndex: number;
|
||||
isSearching: boolean;
|
||||
error: string | null;
|
||||
rawQuery: string;
|
||||
container?: HTMLDivElement | null;
|
||||
onCommit?: (path: string) => void;
|
||||
onHover?: (index: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
results,
|
||||
hoveredIndex,
|
||||
isSearching,
|
||||
error,
|
||||
rawQuery,
|
||||
container = $bindable(null),
|
||||
onCommit,
|
||||
onHover
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
class="max-h-48 overflow-y-auto py-2"
|
||||
transition:fly={{ y: FLY_Y_PX, duration: FLY_DURATION_MS }}
|
||||
>
|
||||
{#if isSearching && results.length === 0}
|
||||
<div class="px-2 py-1.5 text-sm text-muted-foreground">Searching...</div>
|
||||
{:else if error}
|
||||
<div class="px-2 py-1.5 text-sm text-destructive">{error}</div>
|
||||
{:else if results.length === 0}
|
||||
<div class="px-2 py-1.5 text-sm text-muted-foreground">No matching folders</div>
|
||||
{:else}
|
||||
{#each results as path, index (path)}
|
||||
<button
|
||||
type="button"
|
||||
data-result-index={index}
|
||||
data-highlighted={index === hoveredIndex ? '' : undefined}
|
||||
class={cn(
|
||||
'relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground'
|
||||
)}
|
||||
onclick={() => onCommit?.(path)}
|
||||
onmouseenter={() => onHover?.(index)}
|
||||
>
|
||||
<Folder class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-left">
|
||||
{#each highlightMatch(path, rawQuery.trim()) as seg, segIndex (segIndex)}
|
||||
{#if seg.match}
|
||||
<mark class="rounded bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-500/30"
|
||||
>{seg.text}</mark
|
||||
>
|
||||
{:else}
|
||||
{seg.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -12,6 +12,7 @@
|
||||
ChatMessageAssistant,
|
||||
ChatMessageUser,
|
||||
ChatMessageSystem,
|
||||
ChatMessageSynthetic,
|
||||
ChatMessageMcpPrompt
|
||||
} from '$lib/components/app/chat';
|
||||
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
|
||||
@@ -56,6 +57,10 @@
|
||||
: message.content
|
||||
);
|
||||
|
||||
// Synthetic cwd-change messages render with the folder-row UI instead
|
||||
// of a user bubble. The persisted flag is the single source of truth.
|
||||
let isSynthetic = $derived(Boolean(message.isSynthetic));
|
||||
|
||||
let rawEditContent = $derived.by(() => {
|
||||
if (message.role !== MessageRole.ASSISTANT) return undefined;
|
||||
|
||||
@@ -344,7 +349,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-message">
|
||||
<div class="chat-message" class:chat-message--synthetic={isSynthetic}>
|
||||
{#if message.role === MessageRole.SYSTEM}
|
||||
<ChatMessageSystem
|
||||
bind:textareaElement
|
||||
@@ -375,6 +380,8 @@
|
||||
{showDeleteDialog}
|
||||
{siblingInfo}
|
||||
/>
|
||||
{:else if isSynthetic}
|
||||
<ChatMessageSynthetic {message} class={className} />
|
||||
{:else if message.role === MessageRole.USER}
|
||||
<ChatMessageUser
|
||||
class={className}
|
||||
@@ -422,7 +429,17 @@
|
||||
* once known; 500px sizes messages that have never been rendered.
|
||||
*/
|
||||
.chat-message {
|
||||
--chat-message-intrinsic-size: 500px;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 500px;
|
||||
contain-intrinsic-size: auto var(--chat-message-intrinsic-size);
|
||||
}
|
||||
|
||||
/*
|
||||
* Synthetic rows (e.g. the working-directory change) are small, so an
|
||||
* accurate placeholder keeps the injected row from inflating the
|
||||
* auto-scroll offset; the 500px default is for ordinary bubbles.
|
||||
*/
|
||||
.chat-message--synthetic {
|
||||
--chat-message-intrinsic-size: 40px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Folder, FolderX } from '@lucide/svelte';
|
||||
import { parseCwdMessage } from '$lib/utils';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
message: DatabaseMessage;
|
||||
}
|
||||
|
||||
let { class: className = '', message }: Props = $props();
|
||||
|
||||
// Parse the synthetic message content in the UI so the row reuses the
|
||||
// exact same text the model saw, including any guidance suffix.
|
||||
let info = $derived(parseCwdMessage(message.content));
|
||||
</script>
|
||||
|
||||
{#if info}
|
||||
<div class="text-muted-foreground flex items-center gap-2 py-1.5 {className}">
|
||||
{#if info.path === null}
|
||||
<FolderX class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
|
||||
<span class="text-foreground/80 text-sm font-medium">Working directory cleared</span>
|
||||
{:else}
|
||||
<Folder class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
|
||||
<span class="text-foreground/80 text-sm font-medium">Set working directory to </span>
|
||||
<span class="font-mono text-foreground/90 text-sm break-all" title={info.path}>
|
||||
{info.display}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { parseCwdMessage } from '$lib/utils';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
import ChatMessageCwdChange from './ChatMessageCwdChange.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
message: DatabaseMessage;
|
||||
}
|
||||
|
||||
let { class: className = '', message }: Props = $props();
|
||||
|
||||
// Synthetic messages render a dedicated UI, never a user bubble. The only
|
||||
// kind today is the working-directory change; parse the content so the
|
||||
// row reuses the exact synthetic text (and future kinds slot in here).
|
||||
let isCwdChange = $derived(parseCwdMessage(message.content) !== null);
|
||||
</script>
|
||||
|
||||
{#if isCwdChange}
|
||||
<ChatMessageCwdChange {message} class={className} />
|
||||
{:else}
|
||||
<span class="text-muted-foreground block text-sm {className}">{message.content}</span>
|
||||
{/if}
|
||||
+3
@@ -12,6 +12,7 @@
|
||||
import ChatMessageToolCallBlockExecShellCommand from './ChatMessageToolCallBlockExecShellCommand.svelte';
|
||||
import ChatMessageToolCallBlockFileGlobSearch from './ChatMessageToolCallBlockFileGlobSearch.svelte';
|
||||
import ChatMessageToolCallBlockGetDatetime from './ChatMessageToolCallBlockGetDatetime.svelte';
|
||||
import ChatMessageToolCallBlockGetInfo from './ChatMessageToolCallBlockGetInfo.svelte';
|
||||
import ChatMessageToolCallBlockGrepSearch from './ChatMessageToolCallBlockGrepSearch.svelte';
|
||||
import ChatMessageToolCallBlockReadFile from './ChatMessageToolCallBlockReadFile.svelte';
|
||||
import ChatMessageToolCallBlockRunJavascript from './ChatMessageToolCallBlockRunJavascript.svelte';
|
||||
@@ -40,6 +41,8 @@
|
||||
<ChatMessageToolCallBlockSearchResults {section} {open} {isStreaming} {onToggle} />
|
||||
{:else if section.toolName === BuiltInTool.GET_DATETIME}
|
||||
<ChatMessageToolCallBlockGetDatetime {section} {isStreaming} />
|
||||
{:else if section.toolName === BuiltInTool.GET_INFO}
|
||||
<ChatMessageToolCallBlockGetInfo {section} {isStreaming} />
|
||||
{:else if section.toolName === BuiltInTool.READ_FILE}
|
||||
<ChatMessageToolCallBlockReadFile {section} {open} {isStreaming} {onToggle} />
|
||||
{:else if section.toolName === BuiltInTool.EDIT_FILE}
|
||||
|
||||
+6
-2
@@ -1,7 +1,8 @@
|
||||
<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 { computeLineDiff, prefixFor, abbreviateHome, type AgenticSection } from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { parseEditFileMeta } from './parsers/edit-file';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
@@ -15,6 +16,7 @@
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
|
||||
const editFileMeta = $derived(parseEditFileMeta(section));
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
const editDiffs = $derived(
|
||||
(editFileMeta?.edits ?? []).map((edit) => computeLineDiff(edit.oldText, edit.newText))
|
||||
);
|
||||
@@ -23,7 +25,9 @@
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={editFileMeta} {onToggle}>
|
||||
{#snippet titleSnippet()}
|
||||
<span class="text-muted-foreground">Edit file </span>
|
||||
<span class="font-mono">{editFileMeta?.filePath}</span>
|
||||
<span class="font-mono" title={editFileMeta?.filePath}
|
||||
>{abbreviateHome(editFileMeta?.filePath ?? '', home)}</span
|
||||
>
|
||||
{#if editFileMeta?.errorMessage}
|
||||
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
|
||||
{/if}
|
||||
|
||||
+32
@@ -12,6 +12,7 @@
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import { TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
|
||||
import {
|
||||
abbreviateHome,
|
||||
highlightCode,
|
||||
isExitCodeSummaryLine,
|
||||
parseExecShellCommandError,
|
||||
@@ -21,6 +22,7 @@
|
||||
type ExecShellExitStatus,
|
||||
type ToolResultLine
|
||||
} from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
|
||||
import type { DatabaseMessageExtra } from '$lib/types';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
@@ -75,6 +77,14 @@
|
||||
execShellMeta ? highlightCode(execShellMeta.command, 'bash') : ''
|
||||
);
|
||||
|
||||
// The working directory the command ran with, persisted per call on the
|
||||
// tool result message (it travels via the x-tool-cwd header, not the tool
|
||||
// args). Reading it from the section keeps it accurate even if the
|
||||
// conversation cwd changes later.
|
||||
const cwd = $derived(section.toolCwd);
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
const wdDisplay = $derived(abbreviateHome(cwd ?? '', home));
|
||||
|
||||
const exitBadgeClass = $derived(
|
||||
execShellExitStatus?.timedOut
|
||||
? 'exit-badge warning'
|
||||
@@ -159,6 +169,11 @@
|
||||
</script>
|
||||
|
||||
{#snippet execShellTitle()}
|
||||
{#if cwd}
|
||||
<span class="exec-wd" title={cwd}>{wdDisplay}</span>
|
||||
<span class="exec-prompt">$</span>
|
||||
{/if}
|
||||
|
||||
{#if highlightedCommandHtml}
|
||||
<span class="font-mono">{@html highlightedCommandHtml}</span>
|
||||
{:else}
|
||||
@@ -232,6 +247,23 @@
|
||||
</ToolCallBlock>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--exec-wd-margin: 0.4rem;
|
||||
}
|
||||
|
||||
.exec-wd {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--muted-foreground);
|
||||
margin-right: var(--exec-wd-margin);
|
||||
}
|
||||
|
||||
.exec-prompt {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--muted-foreground);
|
||||
opacity: 0.55;
|
||||
margin-right: var(--exec-wd-margin);
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
+6
-2
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { XCircle } from '@lucide/svelte';
|
||||
import { type AgenticSection } from '$lib/utils';
|
||||
import { abbreviateHome, type AgenticSection } from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { parseFileGlobSearchMeta } from './parsers/file-glob-search';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
@@ -14,6 +15,7 @@
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
|
||||
const fileGlobMeta = $derived(parseFileGlobSearchMeta(section));
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={fileGlobMeta} {onToggle}>
|
||||
@@ -26,7 +28,9 @@
|
||||
<span class="font-mono">{fileGlobMeta.include}</span>
|
||||
{/if}
|
||||
<span class="text-muted-foreground"> in </span>
|
||||
<span class="font-mono">{fileGlobMeta.path}</span>
|
||||
<span class="font-mono" title={fileGlobMeta.path}
|
||||
>{abbreviateHome(fileGlobMeta.path, home)}</span
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { Info, Loader2 } from '@lucide/svelte';
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import { abbreviateHome, type AgenticSection } from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
|
||||
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 GetInfoMeta = {
|
||||
os?: string;
|
||||
cwd?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
function parseGetInfoMeta(toolResultString: string | undefined): GetInfoMeta {
|
||||
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 };
|
||||
return {
|
||||
os: typeof obj.os === 'string' ? obj.os : undefined,
|
||||
cwd: typeof obj.cwd === 'string' ? obj.cwd : undefined
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// not JSON - nothing to show
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
const infoMeta = $derived(parseGetInfoMeta(section.toolResult));
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
const cwdDisplay = $derived(abbreviateHome(infoMeta.cwd ?? '', home));
|
||||
</script>
|
||||
|
||||
<div class="text-muted-foreground flex items-center gap-2 py-1.5">
|
||||
<Info class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
|
||||
{#if showSpinner}
|
||||
<span class="text-foreground/80 text-sm font-medium">Runtime info</span>
|
||||
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
|
||||
{:else if infoMeta.errorMessage}
|
||||
<span class="text-foreground/80 text-sm font-medium">Runtime info </span>
|
||||
<span class="text-red-600 text-xs italic dark:text-red-400">- {infoMeta.errorMessage}</span
|
||||
>
|
||||
{:else if infoMeta.os || infoMeta.cwd}
|
||||
<span class="text-foreground/80 text-sm font-medium">Runtime info </span>
|
||||
{#if infoMeta.os}
|
||||
<span class="font-mono text-foreground/90 text-sm">{infoMeta.os}</span>
|
||||
{/if}
|
||||
{#if infoMeta.cwd}
|
||||
<span class="font-mono text-foreground/90 text-sm" title={infoMeta.cwd}>{cwdDisplay}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-foreground/80 text-sm font-medium">Runtime info</span>
|
||||
{/if}
|
||||
</div>
|
||||
+4
-2
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { XCircle } from '@lucide/svelte';
|
||||
import { type AgenticSection } from '$lib/utils';
|
||||
import { abbreviateHome, type AgenticSection } from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { parseGrepSearchMeta } from './parsers/grep-search';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
@@ -14,6 +15,7 @@
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
|
||||
const grepMeta = $derived(parseGrepSearchMeta(section));
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
</script>
|
||||
|
||||
<ToolCallBlock {section} {open} {isStreaming} meta={grepMeta} {onToggle}>
|
||||
@@ -22,7 +24,7 @@
|
||||
<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>
|
||||
<span class="font-mono" title={grepMeta.path}>{abbreviateHome(grepMeta.path, home)}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
|
||||
+6
-2
@@ -2,7 +2,8 @@
|
||||
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 { abbreviateHome, type AgenticSection } from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { parseWriteFileMeta } from './parsers/write-file';
|
||||
import ToolCallBlock from './ToolCallBlock.svelte';
|
||||
|
||||
@@ -16,12 +17,15 @@
|
||||
let { section, open, isStreaming, onToggle }: Props = $props();
|
||||
|
||||
const writeFileMeta = $derived(parseWriteFileMeta(section));
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
</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>
|
||||
<span class="font-mono" title={writeFileMeta?.filePath}
|
||||
>{abbreviateHome(writeFileMeta?.filePath ?? '', home)}</span
|
||||
>
|
||||
{#if writeFileMeta?.errorMessage}
|
||||
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
|
||||
{/if}
|
||||
|
||||
@@ -272,6 +272,16 @@ export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResou
|
||||
*/
|
||||
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
|
||||
|
||||
/**
|
||||
* Working directory selector for agent mode. Renders a chip below the chat
|
||||
* form; clicking it opens a popover with a directory picker backed by the
|
||||
* server's `file_glob_search` built-in tool (POST /tools). The picked
|
||||
* directory is exposed via `bind:directory`; changing it records a
|
||||
* synthetic "Set working directory to ..." user message into chat history
|
||||
* and is enforced on tool calls via the `x-tool-cwd` request header.
|
||||
*/
|
||||
export { default as ChatFormWorkingDirectory } from './ChatForm/ChatFormWorkingDirectory.svelte';
|
||||
|
||||
/**
|
||||
* **ChatFormPickerMcpPrompts** - MCP prompt selection interface
|
||||
*
|
||||
@@ -557,6 +567,22 @@ export { default as ChatMessageStatisticsBadge } from './ChatMessages/ChatMessag
|
||||
*/
|
||||
export { default as ChatMessageMcpPrompt } from './ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte';
|
||||
|
||||
/**
|
||||
* Synthetic working-directory-change message. Rendered in place of a user
|
||||
* bubble when the message content parses as a cwd message (see
|
||||
* parseCwdMessage); shows the new cwd with the same folder-row treatment
|
||||
* the tool-call UI used.
|
||||
*/
|
||||
export { default as ChatMessageCwdChange } from './ChatMessages/ChatMessage/ChatMessageCwdChange.svelte';
|
||||
|
||||
/**
|
||||
* Generic wrapper for UI-generated (synthetic) messages. Routes the
|
||||
* working-directory change to ChatMessageCwdChange and renders a muted
|
||||
* fallback for any other synthetic text, so no synthetic message ever
|
||||
* surfaces as a user bubble.
|
||||
*/
|
||||
export { default as ChatMessageSynthetic } from './ChatMessages/ChatMessage/ChatMessageSynthetic.svelte';
|
||||
|
||||
/**
|
||||
* Formatted content display for MCP prompt messages. Renders the full prompt
|
||||
* content with arguments in a readable format. Used within ChatMessageMcpPrompt
|
||||
|
||||
Reference in New Issue
Block a user