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
+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;
}