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
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user