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
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/utils';
|
||||
import { parseToolArgs } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
|
||||
import { lastPathSegment, abbreviateHome, formatCwdMessage, parseCwdMessage } from '$lib/utils';
|
||||
import {
|
||||
parseWriteFileMeta,
|
||||
type WriteFileMeta
|
||||
@@ -27,6 +28,90 @@ function makeSection(
|
||||
};
|
||||
}
|
||||
|
||||
describe('lastPathSegment', () => {
|
||||
it('returns the last segment of an absolute path', () => {
|
||||
expect(lastPathSegment('/Users/me/code/my-project')).toBe('my-project');
|
||||
});
|
||||
|
||||
it('returns the last segment of a tilde-relative path', () => {
|
||||
expect(lastPathSegment('~/git/llama.brand')).toBe('llama.brand');
|
||||
});
|
||||
|
||||
it('strips trailing slashes', () => {
|
||||
expect(lastPathSegment('/foo/bar/')).toBe('bar');
|
||||
});
|
||||
|
||||
it('strips multiple trailing slashes', () => {
|
||||
expect(lastPathSegment('/foo/bar///')).toBe('bar');
|
||||
});
|
||||
|
||||
it('returns the input unchanged when there is no slash', () => {
|
||||
expect(lastPathSegment('project')).toBe('project');
|
||||
});
|
||||
|
||||
it('returns tilde when only tilde is given', () => {
|
||||
expect(lastPathSegment('~/')).toBe('~');
|
||||
});
|
||||
});
|
||||
|
||||
describe('abbreviateHome', () => {
|
||||
it('abbreviates paths under home with a tilde', () => {
|
||||
expect(abbreviateHome('/Users/al/Documents/x.txt', '/Users/al')).toBe('~/Documents/x.txt');
|
||||
});
|
||||
|
||||
it('abbreviates home itself to a bare tilde', () => {
|
||||
expect(abbreviateHome('/Users/al', '/Users/al')).toBe('~');
|
||||
});
|
||||
|
||||
it('returns paths outside home unchanged', () => {
|
||||
expect(abbreviateHome('/opt/project', '/Users/al')).toBe('/opt/project');
|
||||
});
|
||||
|
||||
it('does not abbreviate a mere prefix match', () => {
|
||||
expect(abbreviateHome('/Users/alice/x', '/Users/al')).toBe('/Users/alice/x');
|
||||
});
|
||||
|
||||
it('returns the path unchanged when home is unknown', () => {
|
||||
expect(abbreviateHome('/Users/al/Documents', null)).toBe('/Users/al/Documents');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCwdMessage / parseCwdMessage', () => {
|
||||
it('formats a cwd change matching the UI text, with a file link', () => {
|
||||
expect(formatCwdMessage('/Users/al/Documents', '/Users/al')).toBe(
|
||||
'Set working directory to [file:///Users/al/Documents](~/Documents).'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the basename display when home is unknown', () => {
|
||||
expect(formatCwdMessage('/opt/project', null)).toBe(
|
||||
'Set working directory to [file:///opt/project](project).'
|
||||
);
|
||||
});
|
||||
|
||||
it('round-trips through the parser', () => {
|
||||
const info = parseCwdMessage(formatCwdMessage('/Users/al/Documents', '/Users/al'));
|
||||
expect(info?.path).toBe('/Users/al/Documents');
|
||||
expect(info?.display).toBe('~/Documents');
|
||||
});
|
||||
|
||||
it('parses a cwd message even when guidance follows the link', () => {
|
||||
expect(
|
||||
parseCwdMessage(
|
||||
'Set working directory to [file:///a/b](~/b). Tool calls run with this as their working directory.'
|
||||
)
|
||||
).toEqual({ path: '/a/b', display: '~/b' });
|
||||
});
|
||||
|
||||
it('parses the cleared marker', () => {
|
||||
expect(parseCwdMessage('Working directory cleared')).toEqual({ path: null, display: '' });
|
||||
});
|
||||
|
||||
it('returns null for non-cwd content', () => {
|
||||
expect(parseCwdMessage('hello there')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseToolArgs (shared)', () => {
|
||||
it('returns null when the section has no toolArgs', () => {
|
||||
const result = parseToolArgs(BuiltInTool.READ_FILE, makeSection({ toolArgs: undefined }));
|
||||
|
||||
Reference in New Issue
Block a user