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
parent 0713275082
commit 2f56fc3431
41 changed files with 1946 additions and 95 deletions

View File

@@ -10,8 +10,10 @@
#include <ctime>
#include <atomic>
#include <cstring>
#include <cstdlib>
#include <algorithm>
#include <unordered_set>
#include <tuple>
#include <functional>
#include <memory>
@@ -34,7 +36,40 @@ json server_tool::to_json() const {
}
static constexpr size_t SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT = 8 * 1024 * 1024; // 8 MB
static constexpr int SERVER_TOOL_GIT_LS_FILES_TIMEOUT = 15; // seconds
// budget for one listing call, shared by the git and walker paths
static constexpr int SERVER_TOOL_LIST_ENTRIES_TIMEOUT = 15; // seconds
// entry kinds a directory listing may return
enum class list_kind {
files, // regular files only
dirs, // directories only
all, // both
};
// home directory, read once at first use (getenv is not thread safe against setenv)
static const std::string & home_dir() {
static const std::string home = [] {
const char * h = getenv("HOME");
#ifdef _WIN32
if (h == nullptr) h = getenv("USERPROFILE");
#endif
return h ? std::string(h) : std::string();
}();
return home;
}
static std::string expand_home(const std::string & path) {
if (path.empty() || path[0] != '~') return path;
if (path.size() > 1 && path[1] != '/' && path[1] != '\\') return path;
const std::string & home = home_dir();
if (home.empty()) return path;
return home + path.substr(1);
}
// depth of a '/'-separated relative path: "a/b/c" is 3
static int entry_depth(const std::string & rel) {
return 1 + (int) std::count(rel.begin(), rel.end(), '/');
}
class tools_io {
public:
@@ -51,8 +86,17 @@ public:
virtual bool file_size(const std::string & path, uintmax_t & out_size) const = 0;
virtual bool read_file(const std::string & path, std::string & out) const = 0;
virtual bool write_file(const std::string & path, const std::string & content) const = 0;
// paths relative to `base`, '/'-separated; sets `err` if `base` isn't a directory
virtual std::vector<std::string> list_files(const std::string & base, std::string & err) const = 0;
// resolve `path` against the IO's working directory; absolute paths are returned unchanged
virtual std::string resolve(const std::string & path) const = 0;
struct list_entry {
std::string rel; // '/'-separated, relative to `base`
bool is_dir = false;
};
// entries relative to `base`; sets `err` if `base` isn't a directory
// max_depth == 0 means unlimited, 1 means direct children of `base` only
// `base` must already be resolved (absolute); `caller_path` is the path the
// caller passed, used only for error messages
virtual std::vector<list_entry> list_entries(const std::string & base, const std::string & caller_path, int max_depth, list_kind kind, std::string & err, bool & truncated) const = 0;
// on_chunk, if set, is called with each chunk of output as it is read (before truncation cuts in);
// returning false terminates the process early (e.g. the client disconnected)
virtual exec_result run(
@@ -67,6 +111,22 @@ public:
// cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
explicit tools_io_basic(std::string cwd = "") : cwd(std::move(cwd)) {}
// expands a leading `~`, then resolves `path` against `cwd` (or the server
// working directory when `cwd` is unset); the result is always absolute
std::string resolve(const std::string & path) const override {
std::string p = expand_home(path);
if (fs::path(p).is_absolute()) {
return p;
}
if (cwd.empty()) {
std::error_code ec;
fs::path cur = fs::current_path(ec);
if (ec) return p;
return (cur / p).string();
}
return (fs::path(cwd) / p).string();
}
bool is_directory(const std::string & path) const override {
std::error_code ec;
return fs::is_directory(resolve(path), ec) && !ec;
@@ -105,34 +165,41 @@ public:
return (bool) f;
}
std::vector<std::string> list_files(const std::string & base, std::string & err) const override {
std::vector<list_entry> list_entries(const std::string & base, const std::string & caller_path, int max_depth, list_kind kind, std::string & err, bool & truncated) const override {
err.clear();
std::string abs_base = resolve(base);
if (!is_directory(base)) {
err = "path does not exist or is not a directory: " + base;
truncated = false;
std::error_code ec;
if (!fs::is_directory(base, ec) || ec) {
err = "path does not exist or is not a directory: " + caller_path;
return {};
}
auto res = run(
{"git", "-C", abs_base, "ls-files", "--cached", "--others", "--exclude-standard"},
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_GIT_LS_FILES_TIMEOUT);
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(SERVER_TOOL_LIST_ENTRIES_TIMEOUT);
if (res.exit_code == 0 && !res.timed_out) {
std::vector<std::string> result;
std::istringstream iss(res.output);
std::string line;
while (std::getline(iss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.empty()) continue;
std::replace(line.begin(), line.end(), '\\', '/');
if (is_regular_file((fs::path(base) / line).string())) {
result.push_back(line);
// git ls-files cannot list directories; use the walker when they are requested
if (kind == list_kind::files) {
auto res = run(
{"git", "-C", base, "ls-files", "--cached", "--others", "--exclude-standard"},
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_LIST_ENTRIES_TIMEOUT);
if (res.exit_code == 0 && !res.timed_out) {
std::vector<list_entry> result;
std::istringstream iss(res.output);
std::string line;
while (std::getline(iss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.empty()) continue;
std::replace(line.begin(), line.end(), '\\', '/');
if (max_depth > 0 && entry_depth(line) > max_depth) continue;
if (is_regular_file((fs::path(base) / line).string())) {
result.push_back({line, false});
}
}
return result;
}
return result;
}
return list_files_fallback(abs_base);
return list_entries_fallback(base, max_depth, kind, deadline, truncated);
}
exec_result run(
@@ -211,14 +278,6 @@ public:
private:
std::string cwd;
// resolves `path` against `cwd` if `path` is relative and `cwd` is set; otherwise returns `path` unchanged
std::string resolve(const std::string & path) const {
if (cwd.empty() || fs::path(path).is_absolute()) {
return path;
}
return (fs::path(cwd) / path).string();
}
static const std::unordered_set<std::string> & junk_dir_names() {
static const std::unordered_set<std::string> names = {
".git", ".svn", ".hg", "node_modules", "__pycache__",
@@ -227,28 +286,50 @@ private:
return names;
}
std::vector<std::string> list_files_fallback(const std::string & base) const {
std::vector<std::string> result;
std::vector<list_entry> list_entries_fallback(const std::string & base, int max_depth, list_kind kind,
std::chrono::steady_clock::time_point deadline, bool & truncated) const {
std::vector<list_entry> result;
std::error_code ec;
std::vector<std::pair<fs::path, fs::path>> stack;
stack.emplace_back(fs::path(base), fs::path());
std::vector<std::tuple<fs::path, fs::path, int>> stack;
stack.emplace_back(fs::path(base), fs::path(), 0);
while (!stack.empty()) {
auto [dir, rel_dir] = stack.back();
auto [dir, rel_dir, depth] = stack.back();
stack.pop_back();
for (const auto & entry : fs::directory_iterator(dir, fs::directory_options::skip_permission_denied, ec)) {
// the throwing increment would escape the tool on a directory that
// goes away mid walk, so step the iterator explicitly
fs::directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec);
for (const fs::directory_iterator end; it != end; it.increment(ec)) {
if (ec) break;
if (std::chrono::steady_clock::now() >= deadline) {
truncated = true;
return result;
}
const fs::directory_entry & entry = *it;
std::string fname = entry.path().filename().string();
std::error_code tec;
if (entry.is_directory(tec)) {
std::string rel = (rel_dir / fname).string();
std::replace(rel.begin(), rel.end(), '\\', '/');
if (kind == list_kind::dirs || kind == list_kind::all) {
result.push_back({rel, true});
}
// junk directories stay selectable but are never walked: they
// hold nothing worth searching and can be enormous
if (junk_dir_names().count(fname) > 0) continue;
stack.emplace_back(entry.path(), rel_dir / fname);
// do not descend into symlinks: a link can point back to an
// ancestor and loop forever
if (!entry.is_symlink(tec) && (max_depth == 0 || depth + 1 < max_depth)) {
stack.emplace_back(entry.path(), rel_dir / fname, depth + 1);
}
} else if (entry.is_regular_file(tec)) {
std::string rel = (rel_dir / fname).string();
std::replace(rel.begin(), rel.end(), '\\', '/');
result.push_back(rel);
if (kind == list_kind::files || kind == list_kind::all) {
result.push_back({rel, false});
}
}
}
}
@@ -363,6 +444,9 @@ struct server_tool_read_file : server_tool {
//
static constexpr size_t SERVER_TOOL_FILE_SEARCH_MAX_RESULTS = 100;
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_FILE = "file";
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_DIR = "dir";
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_ALL = "all";
struct server_tool_file_glob_search : server_tool {
server_tool_file_glob_search() {
@@ -382,13 +466,18 @@ struct server_tool_file_glob_search : server_tool {
"and common junk directories (.git, node_modules, build, dist, etc.) otherwise. "
"A pattern with no '/' (e.g. \"*.cpp\") matches the file's basename at any depth. "
"A pattern containing '/' matches the full relative path; unless already anchored with "
"\"**/\" or a leading '/', it is automatically prefixed with \"**/\"."},
"\"**/\" or a leading '/', it is automatically prefixed with \"**/\". "
"Use type=\"dir\" or \"all\" to also list directories; directory entries are suffixed with '/' in the output. "
"Note: directory listings do not apply .gitignore filtering."},
{"parameters", {
{"type", "object"},
{"properties", {
{"path", {{"type", "string"}, {"description", "Base directory to search in"}}},
{"include", {{"type", "string"}, {"description", "Glob pattern for files to include (e.g. \"*.cpp\" or \"src/**/*.cpp\"). Default: **"}}},
{"exclude", {{"type", "string"}, {"description", "Glob pattern for files to exclude"}}},
{"path", {{"type", "string"}, {"description", "Base directory to search in"}}},
{"include", {{"type", "string"}, {"description", "Glob pattern for files to include (e.g. \"*.cpp\" or \"src/**/*.cpp\"). Default: **"}}},
{"exclude", {{"type", "string"}, {"description", "Glob pattern for files to exclude"}}},
{"type", {{"type", "string"}, {"description", "Entry type to return: \"file\" (default), \"dir\" or \"all\""}}},
{"max_depth", {{"type", "integer"}, {"description", "Maximum depth to descend into subdirectories (default: 0 = unlimited; 1 = direct children only)"}}},
{"limit", {{"type", "integer"}, {"description", string_format("Maximum number of results to return (default %zu; values below 1 fall back to the default)", SERVER_TOOL_FILE_SEARCH_MAX_RESULTS)}}},
}},
{"required", json::array({"path"})},
}},
@@ -397,30 +486,56 @@ struct server_tool_file_glob_search : server_tool {
}
json invoke(json params, server_tool::stream *) const override {
std::string base = params.at("path").get<std::string>();
std::string include = json_value(params, "include", std::string("**"));
std::string exclude = json_value(params, "exclude", std::string(""));
auto io = make_tools_io(params);
std::string base = io->resolve(params.at("path").get<std::string>());
// normalize to forward slashes so the web UI (which assumes '/') can
// join the relative entries into absolute paths on Windows too
std::replace(base.begin(), base.end(), '\\', '/');
std::string include = json_value(params, "include", std::string("**"));
std::string exclude = json_value(params, "exclude", std::string(""));
std::string type = json_value(params, "type", std::string("file"));
int max_depth = std::max(0, json_value(params, "max_depth", 0));
int limit = json_value(params, "limit", (int) SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
if (limit < 1) limit = SERVER_TOOL_FILE_SEARCH_MAX_RESULTS;
limit = std::min(limit, (int) SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
list_kind kind;
if (type == SERVER_TOOL_FILE_SEARCH_TYPE_FILE) {
kind = list_kind::files;
} else if (type == SERVER_TOOL_FILE_SEARCH_TYPE_DIR) {
kind = list_kind::dirs;
} else if (type == SERVER_TOOL_FILE_SEARCH_TYPE_ALL) {
kind = list_kind::all;
} else {
return {{"error", "invalid type: " + type + " (expected \"file\", \"dir\" or \"all\")"}};
}
std::string err;
auto files = io->list_files(base, err);
bool truncated = false;
auto entries = io->list_entries(base, params.at("path").get<std::string>(), max_depth, kind, err, truncated);
if (!err.empty()) {
return {{"error", err}};
}
std::vector<std::string> matches;
for (const auto & rel : files) {
if (!path_glob_match(include, rel)) continue;
if (!exclude.empty() && path_glob_match(exclude, rel)) continue;
matches.push_back(rel);
std::vector<tools_io::list_entry> matches;
for (const auto & entry : entries) {
if (!path_glob_match(include, entry.rel)) continue;
if (!exclude.empty() && path_glob_match(exclude, entry.rel)) continue;
matches.push_back(entry);
}
size_t total = matches.size();
size_t shown = std::min(total, SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
size_t shown = std::min(total, (size_t) limit);
std::ostringstream output_text;
json entries_json = json::array();
for (size_t i = 0; i < shown; i++) {
output_text << matches[i] << "\n";
output_text << matches[i].rel << (matches[i].is_dir ? "/" : "") << "\n";
entries_json.push_back({
{"path", matches[i].rel},
{"type", matches[i].is_dir ? "dir" : "file"},
});
}
output_text << "\n---\nTotal matches: " << total << "\n";
@@ -429,8 +544,16 @@ struct server_tool_file_glob_search : server_tool {
"[%zu results limit reached (%zu total matches). Refine the glob pattern to narrow the search.]\n",
shown, total);
}
if (truncated) {
output_text << "[search timed out, results truncated]\n";
}
return {{"plain_text_response", output_text.str()}};
// `base` is always absolute (resolve falls back to the server cwd), so
// API clients (e.g. the web UI picker) can join the relative entries
// into absolute paths. `plain_text_response` is what the model sees;
// `entries` is the same data as structured JSON for the UI picker,
// which reads `entries`/`base` instead of re-parsing the text.
return {{"plain_text_response", output_text.str()}, {"entries", entries_json}, {"base", base}};
}
};
@@ -513,18 +636,20 @@ struct server_tool_grep_search : server_tool {
// collect (absolute_path, display_path) pairs to search
std::vector<std::pair<std::string, std::string>> files;
if (io->is_regular_file(path)) {
files.emplace_back(path, path);
} else if (io->is_directory(path)) {
const std::string abs_path = io->resolve(path);
if (io->is_regular_file(abs_path)) {
files.emplace_back(abs_path, path);
} else if (io->is_directory(abs_path)) {
std::string err;
auto candidates = io->list_files(path, err);
bool truncated = false;
auto candidates = io->list_entries(abs_path, path, 0, list_kind::files, err, truncated);
if (!err.empty()) {
return {{"error", err}};
}
for (const auto & rel : candidates) {
if (!path_glob_match(include, rel)) continue;
if (!exclude.empty() && path_glob_match(exclude, rel)) continue;
files.emplace_back((fs::path(path) / rel).string(), rel);
for (const auto & entry : candidates) {
if (!path_glob_match(include, entry.rel)) continue;
if (!exclude.empty() && path_glob_match(exclude, entry.rel)) continue;
files.emplace_back((fs::path(abs_path) / entry.rel).string(), entry.rel);
}
} else {
return {{"error", "path does not exist: " + path}};
@@ -1094,6 +1219,9 @@ struct server_tool_get_datetime : server_tool {
// get_info: returns runtime info (OS name/version and cwd)
//
static constexpr size_t SERVER_TOOL_GET_INFO_MAX_OUTPUT = 4096;
static constexpr int SERVER_TOOL_GET_INFO_TIMEOUT = 5; // seconds
struct server_tool_get_info : server_tool {
server_tool_get_info() {
name = "get_info";
@@ -1119,9 +1247,9 @@ struct server_tool_get_info : server_tool {
auto io = make_tools_io(params);
#ifdef _WIN32
auto res = io->run({"cmd", "/c", "ver"}, 4096, 5);
auto res = io->run({"cmd", "/c", "ver"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
#else
auto res = io->run({"uname", "-a"}, 4096, 5);
auto res = io->run({"uname", "-a"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
#endif
// "ver" prints a blank line before the version, so the output is stripped on both ends;
// a failed spawn or a timeout leaves a diagnostic in res.output, which is not an OS name

View File

@@ -164,3 +164,101 @@ def test_tools_builtin_edit_file_rejects_overlapping_edits():
finally:
if os.path.exists(log_path):
os.remove(log_path)
def test_tools_builtin_file_glob_search_type_dir(tmp_path):
global server
server.start()
(tmp_path / "project-alpha" / "src").mkdir(parents=True)
(tmp_path / "project-alpha" / "README.md").write_text("alpha")
(tmp_path / "project-alpha" / "src" / "main.cpp").write_text("int main() {}")
(tmp_path / "project-beta").mkdir()
(tmp_path / "project-beta" / "notes.txt").write_text("beta")
res = call_tool("file_glob_search", {"path": str(tmp_path), "type": "dir"})
text = res["plain_text_response"]
assert "project-alpha/" in text
assert "project-beta/" in text
assert "project-alpha/src/" in text
assert "README.md" not in text
types = {e["path"]: e["type"] for e in res["entries"]}
assert types["project-alpha"] == "dir"
assert types["project-alpha/src"] == "dir"
res_all = call_tool("file_glob_search", {"path": str(tmp_path), "type": "all", "include": "*proj*"})
paths = [e["path"] for e in res_all["entries"]]
assert "project-alpha" in paths
assert "project-beta" in paths
def test_tools_builtin_file_glob_search_max_depth_and_limit(tmp_path):
global server
server.start()
(tmp_path / "a" / "b" / "c").mkdir(parents=True)
(tmp_path / "top.txt").write_text("top")
(tmp_path / "a" / "mid.txt").write_text("mid")
(tmp_path / "a" / "b" / "deep.txt").write_text("deep")
res = call_tool("file_glob_search", {"path": str(tmp_path), "max_depth": 1})
assert "top.txt" in res["plain_text_response"]
assert "mid.txt" not in res["plain_text_response"]
res = call_tool("file_glob_search", {"path": str(tmp_path), "max_depth": 2})
assert "mid.txt" in res["plain_text_response"]
assert "deep.txt" not in res["plain_text_response"]
res = call_tool("file_glob_search", {"path": str(tmp_path), "limit": 1})
assert len(res["entries"]) == 1
assert "Total matches: 3" in res["plain_text_response"]
def test_tools_builtin_file_glob_search_rejects_invalid_type(tmp_path):
global server
server.start()
err = call_tool_expect_error("file_glob_search", {"path": str(tmp_path), "type": "bogus"})
assert "invalid type" in err
def test_tools_builtin_cwd_header_overrides_model_param(tmp_path):
global server
server.start()
workdir = tmp_path / "workdir"
workdir.mkdir()
(workdir / "marker.txt").write_text("marker")
# a model-provided "cwd" in the params is overridden by the x-tool-cwd header
res = call_tool("read_file", {"path": "marker.txt", "cwd": "/definitely/not/a/real/path"},
headers={"x-tool-cwd": str(workdir)})
assert "marker" in res["plain_text_response"]
def test_tools_builtin_cwd_relative_paths(tmp_path):
global server
server.start()
workdir = tmp_path / "workdir"
workdir.mkdir()
(workdir / "rel.txt").write_text("relative-content")
headers = {"x-tool-cwd": str(workdir)}
# relative paths in file tools resolve against the header cwd
res = call_tool("read_file", {"path": "rel.txt"}, headers=headers)
assert "relative-content" in res["plain_text_response"]
res = call_tool("write_file", {"path": "sub/out.txt", "content": "written"}, headers=headers)
assert (workdir / "sub" / "out.txt").read_text() == "written"
res = call_tool("file_glob_search", {"path": ".", "include": "*.txt"}, headers=headers)
assert "rel.txt" in res["plain_text_response"]
# absolute paths are unaffected by the cwd
other = tmp_path / "other"
other.mkdir()
(other / "abs.txt").write_text("absolute-content")
res = call_tool("read_file", {"path": str(other / "abs.txt")}, headers=headers)
assert "absolute-content" in res["plain_text_response"]

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

View File

@@ -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

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View 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}

View File

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

View File

@@ -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}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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

View File

@@ -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',

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';

View File

@@ -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]*?)\)/;

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',

View File

@@ -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;

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';

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',

View File

@@ -24,6 +24,7 @@ export enum McpPromptVariant {
*/
export enum UrlProtocol {
DATA = 'data:',
FILE = 'file:',
HTTP = 'http:',
HTTPS = 'https:',
WEBSOCKET = 'ws:',

View File

@@ -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);

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,

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;

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: [],

View File

@@ -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;
/**

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();

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

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,

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;

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

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,

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

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

View File

@@ -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 }));

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import {
splitPathQuery,
buildCaseInsensitiveGlob,
rankEntries,
joinPath,
highlightMatch
} from '$lib/utils';
describe('splitPathQuery', () => {
it('treats a plain query as a home-relative glob (not navigation)', () => {
expect(splitPathQuery('docs')).toBeNull();
});
it('navigates the root for `/`', () => {
expect(splitPathQuery('/')).toEqual({ parent: '/', last: '' });
});
it('navigates home for `~`', () => {
expect(splitPathQuery('~')).toEqual({ parent: '~', last: '' });
});
it('splits an absolute path into parent and last segment', () => {
expect(splitPathQuery('/Users/al/proj')).toEqual({ parent: '/Users/al', last: 'proj' });
});
it('navigates a Windows drive path written with backslashes', () => {
expect(splitPathQuery('C:\\repos\\llama.cpp')).toEqual({
parent: 'C:/repos',
last: 'llama.cpp'
});
});
it('navigates a Windows drive path written with forward slashes', () => {
expect(splitPathQuery('D:/repos')).toEqual({ parent: 'D:/', last: 'repos' });
});
it('treats a bare drive as its root', () => {
expect(splitPathQuery('D:')).toEqual({ parent: 'D:/', last: '' });
expect(splitPathQuery('D:\\')).toEqual({ parent: 'D:/', last: '' });
});
it('navigates a UNC share', () => {
expect(splitPathQuery('\\\\host\\share\\proj')).toEqual({
parent: '//host/share/',
last: 'proj'
});
});
it('keeps a backslash as a POSIX filename character', () => {
expect(splitPathQuery('/tmp/a\\b')).toEqual({ parent: '/tmp', last: 'a\\b' });
});
it('splits a home-relative path into parent and last segment', () => {
expect(splitPathQuery('~/Documents')).toEqual({ parent: '~', last: 'Documents' });
});
it('strips trailing slashes before splitting', () => {
expect(splitPathQuery('/Users/al/')).toEqual({ parent: '/Users', last: 'al' });
});
it('handles a single-segment absolute path', () => {
expect(splitPathQuery('/opt')).toEqual({ parent: '/', last: 'opt' });
});
});
describe('buildCaseInsensitiveGlob', () => {
it('wraps letters in case-insensitive character classes', () => {
expect(buildCaseInsensitiveGlob('ab')).toBe('*[aA][bB]*');
});
it('escapes glob metacharacters into literal fragments', () => {
expect(buildCaseInsensitiveGlob('a*b')).toBe('*[aA][*][bB]*');
});
});
describe('rankEntries', () => {
const entries = [
{ path: '/h/README', type: 'dir' },
{ path: '/h/read', type: 'dir' },
{ path: '/h/readme.txt', type: 'dir' }
];
it('ranks exact basename match first', () => {
const ranked = rankEntries(entries, 'read');
expect(ranked[0].path).toBe('/h/read');
});
it('breaks ties by shorter path, then alphabetically', () => {
const ranked = rankEntries(entries, 'read');
expect(ranked[ranked.length - 1].path).toBe('/h/readme.txt');
});
it('does not mutate the input', () => {
const snapshot = [...entries];
rankEntries(entries, 'read');
expect(entries).toEqual(snapshot);
});
});
describe('joinPath', () => {
it('joins base and relative avoiding a double slash', () => {
expect(joinPath('/home/al/', 'docs')).toBe('/home/al/docs');
});
it('returns the relative path when base is empty', () => {
expect(joinPath('', 'docs')).toBe('docs');
});
});
describe('highlightMatch', () => {
it('returns a single non-matching segment when query is empty', () => {
expect(highlightMatch('abc', '')).toEqual([{ text: 'abc', match: false }]);
});
it('marks every case-insensitive occurrence of the query', () => {
expect(highlightMatch('aXa', 'ax')).toEqual([
{ text: 'aX', match: true },
{ text: 'a', match: false }
]);
});
it('returns non-matching text when the query is absent', () => {
expect(highlightMatch('abc', 'z')).toEqual([{ text: 'abc', match: false }]);
});
});