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:
Aleksander Grygier
2026-08-04 19:05:48 +02:00
committed by GitHub
co-authored by Pascal
parent 0713275082
commit 2f56fc3431
41 changed files with 1946 additions and 95 deletions
+9
View File
@@ -142,5 +142,14 @@ declare global {
interface Window {
idxThemeStyle?: number;
idxCodeBlock?: number;
// File System Access API - missing from older DOM lib versions.
// Used by ChatFormWorkingDirectory's native folder picker. Feature availability
// is gated at runtime via `typeof window.showDirectoryPicker === 'function'`.
showDirectoryPicker: (options?: {
id?: string;
mode?: 'read' | 'readwrite';
startIn?: FileSystemHandle | string;
}) => Promise<FileSystemDirectoryHandle>;
}
}
@@ -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>
@@ -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>
@@ -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&nbsp;</span>
<span class="font-mono text-foreground/90 text-sm break-all" title={info.path}>
{info.display}
</span>
{/if}
</div>
{/if}
@@ -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}
@@ -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}
@@ -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}
@@ -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;
}
@@ -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">&nbsp;in&nbsp;</span>
<span class="font-mono">{fileGlobMeta.path}</span>
<span class="font-mono" title={fileGlobMeta.path}
>{abbreviateHome(fileGlobMeta.path, home)}</span
>
{/if}
{/snippet}
@@ -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&nbsp;</span>
<span class="text-red-600 text-xs italic dark:text-red-400">-&nbsp;{infoMeta.errorMessage}</span
>
{:else if infoMeta.os || infoMeta.cwd}
<span class="text-foreground/80 text-sm font-medium">Runtime info&nbsp;</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>
@@ -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&nbsp;</span>
<span class="font-mono">{grepMeta.pattern}</span>
<span class="text-muted-foreground">&nbsp;in&nbsp;</span>
<span class="font-mono">{grepMeta.path}</span>
<span class="font-mono" title={grepMeta.path}>{abbreviateHome(grepMeta.path, home)}</span>
{/if}
{/snippet}
@@ -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
@@ -15,6 +15,7 @@ import {
FilePlus,
FileSearch,
FileText,
Info,
SearchCode,
Terminal
} from '@lucide/svelte';
@@ -41,6 +42,7 @@ export const BUILTIN_TOOL_UI: Readonly<Record<BuiltInTool, BuiltinToolUiEntry>>
source: ToolSource.BUILTIN
},
[BuiltInTool.GET_DATETIME]: { icon: Clock, label: 'Current time', source: ToolSource.BUILTIN },
[BuiltInTool.GET_INFO]: { icon: Info, label: 'Runtime info', source: ToolSource.BUILTIN },
[BuiltInTool.EXEC_SHELL_COMMAND]: {
icon: Terminal,
label: 'Run command',
+2
View File
@@ -40,6 +40,7 @@ export * from './mcp';
export * from './mcp-form';
export * from './mcp-resource';
export * from './message-export';
export * from './path-display';
export * from './model-id';
export * from './model-loading';
export * from './sse';
@@ -60,3 +61,4 @@ export * from './ui';
export * from './uri-template';
export * from './url';
export * from './viewport';
export * from './working-directory';
@@ -0,0 +1,22 @@
/**
* Constants for synthetic working-directory messages.
*
* The synthetic cwd-change message is text the UI renders as a folder row
* and the model sees as a turn reminder. The prefix and cleared marker keep
* the human-readable wording; the file-link regexes parse the
* `[file:///abs/path](display)` payload back out on the UI side.
*/
import { UrlProtocol } from '$lib/enums';
export const CWD_CHANGED_PREFIX = 'Set working directory to ';
export const CWD_CLEARED_TEXT = 'Working directory cleared';
export const HOME_TILDE = '~';
export const HOME_TILDE_PREFIX = '~/'; // tilde plus path separator
/** Scheme prefix of the file link embedded in a synthetic cwd message. */
export const FILE_URI_PREFIX = `${UrlProtocol.FILE}//`;
/** Matches the leading `[file:///abs/path](display)` link; not anchored to the end so trailing guidance may follow. */
export const CWD_LINK_REGEX = /^\[file:\/\/([\s\S]*?)\]\(([\s\S]*?)\)/;
+3
View File
@@ -1,5 +1,8 @@
import { ToolSource } from '$lib/enums/tools.enums';
/** HTTP header carrying the working directory a tool call runs in. The server resolves relative paths against it; the model cannot override it. */
export const X_TOOL_CWD_HEADER = 'x-tool-cwd';
export const TOOL_GROUP_LABELS = {
[ToolSource.BUILTIN]: 'Built-in',
[ToolSource.CUSTOM]: 'JSON Schema',
@@ -0,0 +1,40 @@
/**
* Constants for the working-directory picker's glob search.
*
* The picker glob-matches home-relative names client-side. Character classes
* are built case-insensitively and the reserved glob metacharacters are
* escaped (passed through literally) so a query never changes matching.
*/
export const GLOB_WILDCARD = '*';
/** Character that starts and ends a glob character-class fragment. */
export const GLOB_RANGE_OPEN = '[';
export const GLOB_RANGE_CLOSE = ']';
/** Query characters that carry glob meaning and are passed through literally. */
export const GLOB_SPECIAL_CHARS = '*?[]';
/** Separator Windows accepts alongside `/`, and a legal POSIX filename character. */
export const WINDOWS_SEPARATOR = '\\';
/** `C:`, the drive part of a Windows absolute path. */
export const DRIVE_PREFIX_REGEX = /^[A-Za-z]:/;
/** `C:` or `C:/`, the root of a Windows drive-absolute path. */
export const DRIVE_ROOT_REGEX = /^[A-Za-z]:\/?/;
/** `//host/share` or `//host/share/`, the root of a UNC path. */
export const UNC_ROOT_REGEX = /^\/\/[^/]+\/[^/]+\/?/;
// Search tuning for the picker's file_glob_search calls.
export const SEARCH_DEBOUNCE_MS = 180;
export const SEARCH_LIMIT = 100;
export const MAX_RESULTS_SHOWN = 20;
// Home-relative globs descend deeper than path navigation, which only
// needs the direct children of the parent.
export const SEARCH_MAX_DEPTH = 6;
export const PATH_NAV_MAX_DEPTH = 1;
// Native folder-picker resolution searches a shallow, bounded window.
export const NATIVE_MAX_DEPTH = 4;
export const NATIVE_LIMIT = 20;
+7 -1
View File
@@ -72,6 +72,12 @@ export { ColorMode, HtmlInputType, McpPromptVariant, TooltipSide, UrlProtocol }
export { KeyboardKey } from './keyboard.enums';
export { BuiltInTool, ToolSource, ToolPermissionDecision, ToolResponseField } from './tools.enums';
export {
BuiltInTool,
GlobSearchType,
ToolSource,
ToolPermissionDecision,
ToolResponseField
} from './tools.enums';
export { SplashOrientation } from './splash.enums';
+11
View File
@@ -17,6 +17,16 @@ export enum ToolResponseField {
ERROR = 'error'
}
/**
* Entry types accepted by the `file_glob_search` tool's `type` parameter.
* Mirrors the server-side validation in server-tools.cpp.
*/
export enum GlobSearchType {
FILE = 'file',
DIR = 'dir',
ALL = 'all'
}
/**
* Wire-format identifiers for built-in and frontend tools. The string
* value matches what the model emits in tool call names, so comparing
@@ -30,6 +40,7 @@ export enum BuiltInTool {
EDIT_FILE = 'edit_file',
WRITE_FILE = 'write_file',
GET_DATETIME = 'get_datetime',
GET_INFO = 'get_info',
FILE_GLOB_SEARCH = 'file_glob_search',
GREP_SEARCH = 'grep_search',
EXEC_SHELL_COMMAND = 'exec_shell_command',
+1
View File
@@ -24,6 +24,7 @@ export enum McpPromptVariant {
*/
export enum UrlProtocol {
DATA = 'data:',
FILE = 'file:',
HTTP = 'http:',
HTTPS = 'https:',
WEBSOCKET = 'ws:',
@@ -674,7 +674,8 @@ export class DatabaseService {
serverId: o.serverId,
enabled: o.enabled
}))
: undefined
: undefined,
cwd: sourceConv.cwd
};
await db[IDXDB_TABLES.conversations].add(newConv);
+30 -3
View File
@@ -2,7 +2,7 @@ import { base } from '$app/paths';
import { getJsonHeaders } from '$lib/utils/api-headers';
import { parseSseJsonStream, type SseJsonEvent } from '$lib/utils/sse';
import { apiFetch } from '$lib/utils';
import { API_TOOLS } from '$lib/constants';
import { API_TOOLS, X_TOOL_CWD_HEADER } from '$lib/constants';
import { ToolResponseField } from '$lib/enums';
import type { ToolExecutionResult, ServerBuiltinToolInfo } from '$lib/types';
@@ -18,15 +18,21 @@ export class ToolsService {
/**
* Execute a built-in tool on the server.
*
* @param cwd - Working directory for the tool call, sent as the
* x-tool-cwd request header. The server resolves relative paths
* against it; the model cannot override it.
*/
static async executeTool(
toolName: string,
params: Record<string, unknown>,
signal?: AbortSignal
signal?: AbortSignal,
cwd?: string
): Promise<ToolExecutionResult> {
const result = await apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
method: 'POST',
body: JSON.stringify({ tool: toolName, params }),
headers: cwd ? { [X_TOOL_CWD_HEADER]: cwd } : undefined,
signal
});
@@ -41,6 +47,25 @@ export class ToolsService {
return { content: JSON.stringify(result), isError: false };
}
/**
* Execute a built-in tool and return the raw JSON response. Unlike
* executeTool, this preserves structured fields (e.g. file_glob_search's
* `entries` and `base`) that the flattened ToolExecutionResult drops.
*/
static async executeToolRaw(
toolName: string,
params: Record<string, unknown>,
signal?: AbortSignal,
cwd?: string
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
method: 'POST',
body: JSON.stringify({ tool: toolName, params }),
headers: cwd ? { [X_TOOL_CWD_HEADER]: cwd } : undefined,
signal
});
}
/**
* Stream a built-in tool's output chunks from the server. The server
* `POST /tools` endpoint with `{stream: true}` emits `data: {"chunk": "..."}`
@@ -59,9 +84,11 @@ export class ToolsService {
static async *streamTool(
toolName: string,
params: Record<string, unknown>,
signal?: AbortSignal
signal?: AbortSignal,
cwd?: string
): AsyncGenerator<ToolStreamEvent> {
const headers = getJsonHeaders();
if (cwd) headers[X_TOOL_CWD_HEADER] = cwd;
const response = await fetch(`${base}${API_TOOLS.EXECUTE}`, {
method: 'POST',
headers,
+6 -3
View File
@@ -22,6 +22,7 @@
import { ChatService } from '$lib/services';
import { config } from '$lib/stores/settings.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { modelsStore } from '$lib/stores/models.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
@@ -812,11 +813,12 @@ class AgenticStore {
updateToolResultMessage
) {
const args = this.parseToolArguments(toolCall.function.arguments);
const msg = await createToolResultMessage(toolCall.id, '');
const cwd = conversationsStore.activeConversation?.cwd;
const msg = await createToolResultMessage(toolCall.id, '', undefined, cwd);
createdToolResultMessageId = msg.id;
let accumulated = '';
for await (const ev of ToolsService.streamTool(toolName, args, signal)) {
for await (const ev of ToolsService.streamTool(toolName, args, signal, cwd)) {
if (ev.chunk !== null) {
accumulated += ev.chunk;
await updateToolResultMessage(msg.id, accumulated);
@@ -835,7 +837,8 @@ class AgenticStore {
result = accumulated;
} else if (toolSource === ToolSource.BUILTIN) {
const args = this.parseToolArguments(toolCall.function.arguments);
const executionResult = await ToolsService.executeTool(toolName, args, signal);
const cwd = conversationsStore.activeConversation?.cwd;
const executionResult = await ToolsService.executeTool(toolName, args, signal, cwd);
result = executionResult.content;
+58 -6
View File
@@ -35,9 +35,12 @@ import {
findDescendantMessages,
findLeafNode,
findMessageById,
formatCwdMessage,
isAbortError,
generateConversationTitle
generateConversationTitle,
CWD_CLEARED_TEXT
} from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { classifyContinueIntent } from '$lib/utils/agentic';
import {
MAX_INACTIVE_CONVERSATION_STATES,
@@ -870,7 +873,8 @@ class ChatStore {
content: string,
type: MessageType = MessageType.TEXT,
parent: string = '-1',
extras?: DatabaseMessageExtra[]
extras?: DatabaseMessageExtra[],
isSynthetic?: boolean
): Promise<DatabaseMessage> {
const activeConv = conversationsStore.activeConversation;
if (!activeConv) throw new Error('No active conversation');
@@ -893,7 +897,8 @@ class ChatStore {
timestamp: Date.now(),
toolCalls: '',
children: [],
extra: extras
extra: extras,
isSynthetic
},
parentId
);
@@ -903,6 +908,33 @@ class ChatStore {
return message;
}
/**
* Record a working-directory change into chat history as a synthetic
* user message, so the model sees it on its next turn (the client
* sends the cwd itself via the x-tool-cwd header on tool calls).
* A plain user message is used because some chat templates reject
* tool messages without a preceding tool call.
*/
async recordCwdChange(cwd: string | null): Promise<void> {
const content = cwd
? formatCwdMessage(cwd, await toolsStore.resolveServerHome())
: CWD_CLEARED_TEXT;
// Reuse the trailing cwd row when it is already the last message, so
// repeated picks update it in place instead of stacking another row.
const last = conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1];
if (last && last.role === MessageRole.USER && last.isSynthetic === true) {
await DatabaseService.updateMessage(last.id, { content, isSynthetic: true });
conversationsStore.updateMessageAtIndex(conversationsStore.activeMessages.length - 1, {
content,
isSynthetic: true
});
return;
}
await this.addMessage(MessageRole.USER, content, MessageType.TEXT, '-1', undefined, true);
}
async addSystemPrompt(): Promise<void> {
let activeConv = conversationsStore.activeConversation;
if (!activeConv) {
@@ -1055,6 +1087,7 @@ class ChatStore {
const rootId = await DatabaseService.createRootMessage(currentConv.id);
const currentConfig = config();
const systemPrompt = currentConfig.systemMessage?.toString().trim();
let sysOrRootId = rootId;
if (systemPrompt) {
const systemMessage = await DatabaseService.createSystemMessage(
currentConv.id,
@@ -1062,8 +1095,25 @@ class ChatStore {
rootId
);
conversationsStore.addMessageToActive(systemMessage);
parentIdForUserMessage = systemMessage.id;
} else parentIdForUserMessage = rootId;
sysOrRootId = systemMessage.id;
}
// Reflect a working directory picked on the new-chat screen into
// chat history before the first user message, so the model sees
// it on its first turn. createConversation() has already threaded
// the pending pick onto the conversation.
if (currentConv.cwd) {
const cwdMessage = await this.addMessage(
MessageRole.USER,
formatCwdMessage(currentConv.cwd, await toolsStore.resolveServerHome()),
MessageType.TEXT,
sysOrRootId,
undefined,
true
);
parentIdForUserMessage = cwdMessage.id;
} else {
parentIdForUserMessage = sysOrRootId;
}
}
const userMessage = await this.addMessage(
MessageRole.USER,
@@ -1282,7 +1332,8 @@ class ChatStore {
createToolResultMessage: async (
toolCallId: string,
content: string,
extras?: DatabaseMessageExtra[]
extras?: DatabaseMessageExtra[],
toolCwd?: string
) => {
const msg = await DatabaseService.createMessageBranch(
{
@@ -1291,6 +1342,7 @@ class ChatStore {
role: MessageRole.TOOL,
content,
toolCallId,
toolCwd,
timestamp: Date.now(),
toolCalls: '',
children: [],
@@ -86,6 +86,15 @@ class ConversationsStore {
/** Global (non-conversation-specific) reasoning effort default */
pendingReasoningEffort = $state<ReasoningEffort>(ConversationsStore.loadReasoningEffortDefault());
/**
* Working directory picked on the empty new-chat screen, before any
* conversation exists. Consumed by `chatStore.sendMessage()`, which
* records it into chat history as a synthetic message on first send.
* Cleared by `loadConversation` and `clearActiveConversation` so a
* stale pick can't bleed onto an unrelated chat.
*/
pendingCwd = $state<string | null>(null);
/** Load reasoning effort default from localStorage, DEFAULT defers to the server */
private static loadReasoningEffortDefault(): ReasoningEffort {
if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.DEFAULT;
@@ -250,9 +259,13 @@ class ConversationsStore {
// No MCP override list is seeded: getAllMcpServerOverrides resolves
// servers without a per-conversation override to `mcpServers[i].enabled`,
// and only explicit toggles are stored on the conversation.
// Working directory picked on the new-chat screen gets threaded in
// here too, then cleared so it doesn't bleed onto subsequent new chats.
const conversation = await DatabaseService.createConversation(conversationName, {
reasoningEffort: this.pendingReasoningEffort
reasoningEffort: this.pendingReasoningEffort,
cwd: this.pendingCwd ?? undefined
});
this.pendingCwd = null;
this.conversations = [conversation, ...this.conversations];
this.activeConversation = conversation;
@@ -276,6 +289,10 @@ class ConversationsStore {
return false;
}
// Drop any cwd the user drafted on the empty new-chat screen -
// it doesn't belong to this conversation.
this.pendingCwd = null;
this.activeConversation = conversation;
if (conversation.currNode) {
@@ -306,6 +323,7 @@ class ConversationsStore {
this.activeMessages = [];
// reload defaults so new chats inherit persisted state
this.pendingReasoningEffort = ConversationsStore.loadReasoningEffortDefault();
this.pendingCwd = null;
}
/**
@@ -855,6 +873,42 @@ class ConversationsStore {
}
}
/**
* Sets the working directory for the active conversation. Pass `null` or
* an empty string to clear it, which restores the picker's empty state.
*
* On the empty new-chat screen (no active conversation yet), the value
* is buffered into `pendingCwd` so the user can pick before
* sending the first message; `createConversation()` consumes it.
*
* @param value - Absolute server-side path to the working directory, or null to clear
*/
async setCwd(value: string | null): Promise<void> {
const trimmed = value?.trim() || undefined;
// No chat yet - buffer for the first chat the user creates.
if (!this.activeConversation) {
this.pendingCwd = trimmed ?? null;
return;
}
this.activeConversation = {
...this.activeConversation,
cwd: trimmed
};
await DatabaseService.updateConversation(this.activeConversation.id, {
cwd: trimmed
});
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
if (convIndex !== -1) {
this.conversations[convIndex].cwd = trimmed;
this.conversations = [...this.conversations];
}
this.pendingCwd = null;
}
/**
* Forks a conversation at a specific message, creating a new conversation
* containing messages from root up to the target message, then navigates to it.
@@ -1169,6 +1223,7 @@ if (browser) {
export const conversations = () => conversationsStore.conversations;
export const activeConversation = () => conversationsStore.activeConversation;
export const activeMessages = () => conversationsStore.activeMessages;
export const pendingCwd = () => conversationsStore.pendingCwd;
export const isConversationsInitialized = () => conversationsStore.isInitialized;
/**
+37 -1
View File
@@ -1,11 +1,19 @@
import type { OpenAIToolDefinition, ToolEntry, ToolGroup } from '$lib/types';
import { ToolsService } from '$lib/services/tools.service';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus, JsonSchemaType, ToolCallType, ToolSource } from '$lib/enums';
import {
BuiltInTool,
GlobSearchType,
HealthCheckStatus,
JsonSchemaType,
ToolCallType,
ToolSource
} from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import {
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
buildSandboxToolDefinition,
HOME_TILDE,
TOOL_GROUP_LABELS,
TOOL_SERVER_LABELS
} from '$lib/constants';
@@ -20,6 +28,7 @@ class ToolsStore {
private _error = $state<string | null>(null);
private _disabledTools = $state(new SvelteSet<string>());
private _toolsEndpointUnreachable = $state(false);
private _serverHome = $state<string | null | undefined>(undefined);
constructor() {
try {
@@ -138,6 +147,10 @@ class ToolsStore {
return this._builtinTools;
}
get serverHome(): string | null {
return this._serverHome ?? null;
}
get mcpTools(): OpenAIToolDefinition[] {
return this.mcpEntries().map((e) => e.definition);
}
@@ -488,6 +501,29 @@ class ToolsStore {
this._loading = false;
}
}
/**
* Absolute home directory on the server, resolved once per session via
* file_glob_search's `base` field (the server expands `~`). Anchors the
* directory picker's search scope and the `~` abbreviation of cwd
* displays. Returns null when tools are unavailable.
*/
async resolveServerHome(): Promise<string | null> {
if (this._serverHome !== undefined) return this._serverHome;
try {
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
path: HOME_TILDE,
type: GlobSearchType.DIR,
max_depth: 1,
limit: 1
});
this._serverHome = typeof res.base === 'string' ? res.base : null;
} catch {
// searches still work via a literal `~`, only `~` abbreviation degrades
this._serverHome = null;
}
return this._serverHome;
}
}
export const toolsStore = new ToolsStore();
+2 -1
View File
@@ -109,7 +109,8 @@ export interface AgenticFlowCallbacks {
createToolResultMessage?: (
toolCallId: string,
content: string,
extras?: DatabaseMessageExtra[]
extras?: DatabaseMessageExtra[],
toolCwd?: string
) => Promise<DatabaseMessage>;
/** Update an already-created tool result message. Used while a streaming
* tool (e.g. exec_shell_command) accumulates output chunks before its
+2 -1
View File
@@ -108,7 +108,8 @@ export interface ChatStreamCallbacks {
createToolResultMessage?: (
toolCallId: string,
content: string,
extras?: DatabaseMessageExtra[]
extras?: DatabaseMessageExtra[],
toolCwd?: string
) => Promise<DatabaseMessage>;
updateToolResultMessage?: (
messageId: string,
+5
View File
@@ -14,6 +14,7 @@ export interface DatabaseConversation {
mcpServerOverrides?: McpServerOverride[];
thinkingEnabled?: boolean;
reasoningEffort?: ReasoningEffort;
cwd?: string;
forkedFromConversationId?: string;
pinned?: boolean;
}
@@ -119,6 +120,10 @@ export interface DatabaseMessage {
completionId?: string;
/** Tool call ID for tool result messages (role: 'tool') */
toolCallId?: string;
/** Working directory the tool call ran with (sent via the x-tool-cwd header), stored per call so the UI can show it accurately even after the conversation cwd changes */
toolCwd?: string;
/** Internal flag marking a UI-generated message (e.g. a cwd change). The row is sent to the model as a "user" turn so chat templates accept it; the flag is only read by the renderer. */
isSynthetic?: boolean;
children: string[];
extra?: DatabaseMessageExtra[];
timings?: ChatMessageTimings;
+4
View File
@@ -38,6 +38,9 @@ export interface AgenticSection {
toolArgs?: string;
toolResult?: string;
toolResultExtras?: DatabaseMessageExtra[];
/** Working directory the tool call ran with (from the tool result
* message), shown by the exec_shell_command renderer. */
toolCwd?: string;
/** ID of the model-side tool call (matches tool_calls[i].id). Lets
* downstream consumers correlate a section with the agentic loop's
* currently-executing tool, e.g. to drive live-streaming UI state
@@ -116,6 +119,7 @@ function deriveSingleTurnSections(
toolArgs: tc.function?.arguments,
toolResult: resultMsg?.content,
toolResultExtras: resultMsg?.extra,
toolCwd: resultMsg?.toolCwd,
toolCallId: tc.id
});
}
+23
View File
@@ -158,6 +158,29 @@ export { createBase64DataUrl } from './data-url';
// Header utilities
export { parseHeadersToArray, serializeHeaders } from './headers';
// Working-directory display helpers (HOME-style tilde abbreviation)
export {
abbreviateWorkingDir,
abbreviateHome,
lastPathSegment,
formatCwdMessage,
parseCwdMessage,
CWD_CHANGED_PREFIX,
CWD_CLEARED_TEXT,
type CwdMessageInfo
} from './path-display';
// Working-directory picker search helpers
export {
splitPathQuery,
buildCaseInsensitiveGlob,
rankEntries,
joinPath,
highlightMatch,
type GlobEntry,
type PathQuery
} from './working-directory';
// Agentic content utilities (structured section derivation)
export {
deriveAgenticSections,
+93
View File
@@ -0,0 +1,93 @@
import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
import { TRAILING_SLASHES_REGEX } from '$lib/constants/url';
import {
CWD_CHANGED_PREFIX,
CWD_CLEARED_TEXT,
CWD_LINK_REGEX,
FILE_URI_PREFIX,
HOME_TILDE,
HOME_TILDE_PREFIX
} from '$lib/constants';
/**
* Last non-empty slash-delimited segment of `path`, with trailing
* slashes stripped. Returns the input unchanged when no `/` is present.
*/
export function lastPathSegment(p: string): string {
const trimmed = p.replace(TRAILING_SLASHES_REGEX, '');
const idx = trimmed.lastIndexOf(PATH_SEPARATOR);
return idx === -1 ? trimmed : trimmed.slice(idx + 1);
}
/**
* Abbreviate `path` to `~/...` when it sits under `home`, or to `~` when
* it equals `home`. Falls back to `lastPathSegment(path)` when home is
* unknown or the path is outside it. `~` semantics are reserved for the
* home directory, mirroring how shells render it.
*/
export function abbreviateWorkingDir(
path: string | null | undefined,
home: string | null | undefined
): string {
if (!path) return '';
if (!home) return lastPathSegment(path);
if (path === home) return HOME_TILDE;
if (path.startsWith(home + PATH_SEPARATOR))
return HOME_TILDE_PREFIX + path.slice(home.length + 1);
return lastPathSegment(path);
}
/**
* Replace a leading `home` prefix in `path` with `~`. Unlike
* abbreviateWorkingDir, paths outside `home` (or an unknown home) are
* returned unchanged - used for tool-call path displays where the full
* path matters.
*/
export function abbreviateHome(path: string, home: string | null | undefined): string {
if (!home) return path;
if (path === home) return HOME_TILDE;
if (path.startsWith(home + PATH_SEPARATOR))
return HOME_TILDE_PREFIX + path.slice(home.length + 1);
return path;
}
export { CWD_CHANGED_PREFIX, CWD_CLEARED_TEXT } from '$lib/constants';
export interface CwdMessageInfo {
// absolute server-side path, null when the cwd was cleared
path: string | null;
// display form shown in the UI (e.g. ~/Documents)
display: string;
}
/**
* Format a synthetic cwd-change message. The text mirrors what the UI
* renders for it; the path travels as `[file:///abs/path](display)` so
* both the absolute and the short form are visible to the model and
* parseable back by the UI.
*/
export function formatCwdMessage(cwd: string, home: string | null): string {
const display = abbreviateWorkingDir(cwd, home);
return `${CWD_CHANGED_PREFIX}[${FILE_URI_PREFIX}${cwd}](${display}).`;
}
/**
* Parse a synthetic cwd message back into its parts. The caller must already
* know the message is synthetic (via the persisted `isSynthetic` flag); this
* only extracts the path from the message text. Returns null when `content`
* is not a cwd message.
*/
export function parseCwdMessage(content: string): CwdMessageInfo | null {
const trimmed = content.trim();
if (trimmed === CWD_CLEARED_TEXT) {
return { path: null, display: '' };
}
if (trimmed.startsWith(CWD_CHANGED_PREFIX)) {
const rest = trimmed.slice(CWD_CHANGED_PREFIX.length);
// not anchored to the end: guidance may follow the link
const link = rest.match(CWD_LINK_REGEX);
if (link) return { path: link[1], display: link[2] };
return { path: rest, display: rest };
}
return null;
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Pure helpers for the working-directory picker search.
*
* The picker is backed by the server's `file_glob_search` built-in tool.
* Queries that start from a root (`/`, `C:\`, `\\host\share`) or from `~`
* navigate the directory tree (search the parent for the last segment);
* anything else glob-matches home-relative entries. Paths are carried with
* `/` separators, which is what the server returns and what Windows accepts.
* These helpers build the glob, normalize results and rank them
* client-side; the component owns the network/state plumbing.
*/
import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
import { TRAILING_SLASHES_REGEX } from '$lib/constants/url';
import {
DRIVE_PREFIX_REGEX,
DRIVE_ROOT_REGEX,
GLOB_RANGE_CLOSE,
GLOB_RANGE_OPEN,
GLOB_SPECIAL_CHARS,
GLOB_WILDCARD,
HOME_TILDE,
LEADING_SLASHES_REGEX,
UNC_ROOT_REGEX,
WINDOWS_SEPARATOR
} from '$lib/constants';
import { lastPathSegment } from './path-display';
export interface GlobEntry {
path: string;
type: string;
}
export interface PathQuery {
parent: string;
last: string;
}
/**
* Rewrite `\` into `/` when the query carries a Windows root. Elsewhere the
* backslash is left alone: it is a legal filename character on POSIX.
*/
function toPosixSeparators(query: string): string {
if (!DRIVE_PREFIX_REGEX.test(query) && !query.startsWith(WINDOWS_SEPARATOR)) return query;
return query.split(WINDOWS_SEPARATOR).join(PATH_SEPARATOR);
}
/**
* Length of the root prefix of `path`, or 0 when it has none. Covers the
* POSIX root, a Windows drive (`C:/`) and a UNC share (`//host/share/`).
*/
export function rootPrefixLength(path: string): number {
const unc = path.match(UNC_ROOT_REGEX);
if (unc) return unc[0].length;
const drive = path.match(DRIVE_ROOT_REGEX);
if (drive) return drive[0].length;
return path.startsWith(PATH_SEPARATOR) ? PATH_SEPARATOR.length : 0;
}
/** A query starting from a root or from `~` is path navigation, not a home-relative glob. */
export function splitPathQuery(query: string): PathQuery | null {
const normalized = toPosixSeparators(query);
const rootLength = rootPrefixLength(normalized);
if (rootLength === 0 && !normalized.startsWith(HOME_TILDE)) return null;
// a root keeps its trailing separator so it stays absolute on its own
const root =
rootLength > 0
? normalized.slice(0, rootLength).replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR
: HOME_TILDE;
const rest = normalized
.slice(rootLength > 0 ? rootLength : HOME_TILDE.length)
.replace(LEADING_SLASHES_REGEX, '')
.replace(TRAILING_SLASHES_REGEX, '');
const parentOf = (dirs: string) =>
rootLength > 0 ? root + dirs : HOME_TILDE + PATH_SEPARATOR + dirs;
if (!rest) return { parent: root, last: '' };
const idx = rest.lastIndexOf(PATH_SEPARATOR);
if (idx === -1) return { parent: root, last: rest };
return { parent: parentOf(rest.slice(0, idx)), last: rest.slice(idx + 1) };
}
/** Build a case-insensitive glob that matches `query` anywhere within a name. */
export function buildCaseInsensitiveGlob(query: string): string {
let out = GLOB_WILDCARD;
for (const c of query) {
const lo = c.toLowerCase();
const up = c.toUpperCase();
if (lo !== up) out += GLOB_RANGE_OPEN + lo + up + GLOB_RANGE_CLOSE;
// glob metacharacters are escaped into a literal character class so a
// query like "a*b" matches a literal '*' instead of becoming "ab"
else if (GLOB_SPECIAL_CHARS.includes(c)) out += GLOB_RANGE_OPEN + c + GLOB_RANGE_CLOSE;
else out += c;
}
return out + GLOB_WILDCARD;
}
/** Exact basename first, then prefix, then substring; lower is better. */
const RANK_EXACT = 0;
const RANK_PREFIX = 1;
const RANK_SUBSTRING = 2;
const RANK_OTHER = 3;
function rankScore(path: string, query: string): number {
const name = lastPathSegment(path).toLowerCase();
const q = query.toLowerCase();
if (name === q) return RANK_EXACT;
if (name.startsWith(q)) return RANK_PREFIX;
if (name.includes(q)) return RANK_SUBSTRING;
return RANK_OTHER;
}
/** Sort entries by relevance, then shorter path, then alphabetically. */
export function rankEntries(entries: GlobEntry[], query: string): GlobEntry[] {
return [...entries].sort(
(a, b) =>
rankScore(a.path, query) - rankScore(b.path, query) ||
a.path.length - b.path.length ||
a.path.localeCompare(b.path)
);
}
/** Join a base path and a relative segment, avoiding duplicate slashes. */
export function joinPath(base: string, rel: string): string {
if (!base) return rel;
return base.replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR + rel;
}
/** Split `text` into alternating segments at each case-insensitive `query` match. */
export function highlightMatch(text: string, query: string): { text: string; match: boolean }[] {
if (!query) return [{ text, match: false }];
const segments: { text: string; match: boolean }[] = [];
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
let i = 0;
while (i < text.length) {
const idx = lowerText.indexOf(lowerQuery, i);
if (idx < 0) {
segments.push({ text: text.slice(i), match: false });
break;
}
if (idx > i) segments.push({ text: text.slice(i, idx), match: false });
segments.push({ text: text.slice(idx, idx + query.length), match: true });
i = idx + query.length;
}
return segments;
}