#include <ctime>
#include <atomic>
#include <cstring>
+#include <cstdlib>
#include <algorithm>
#include <unordered_set>
+#include <tuple>
#include <functional>
#include <memory>
}
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:
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(
// 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;
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);
-
- 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);
+ const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(SERVER_TOOL_LIST_ENTRIES_TIMEOUT);
+
+ // 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(
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__",
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});
+ }
}
}
}
//
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() {
"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"})},
}},
}
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";
"[%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}};
}
};
// 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}};
// 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";
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
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"]
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>;
}
}
ChatFormMcpResourcesList,
ChatFormPickers,
ChatFormTextarea,
+ ChatFormWorkingDirectory,
DialogMcpResourcesBrowser
} from '$lib/components/app';
import {
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 {
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);
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();
}
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
<form
- class="relative {className}"
+ class="relative grid {className}"
onsubmit={(event) => {
event.preventDefault();
</div>
<ContextGaugePopup />
+
+ {#if toolsStore.builtinTools.length > 0}
+ <ChatFormWorkingDirectory
+ directory={cwd}
+ onChange={handleWorkingDirectoryChange}
+ onClose={refocusInput}
+ {disabled}
+ />
+ {/if}
</form>
<DialogMcpResourcesBrowser
--- /dev/null
+<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 />
--- /dev/null
+<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>
--- /dev/null
+<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>
ChatMessageAssistant,
ChatMessageUser,
ChatMessageSystem,
+ ChatMessageSynthetic,
ChatMessageMcpPrompt
} from '$lib/components/app/chat';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
: 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;
}
</script>
-<div class="chat-message">
+<div class="chat-message" class:chat-message--synthetic={isSynthetic}>
{#if message.role === MessageRole.SYSTEM}
<ChatMessageSystem
bind:textareaElement
{showDeleteDialog}
{siblingInfo}
/>
+ {:else if isSynthetic}
+ <ChatMessageSynthetic {message} class={className} />
{:else if message.role === MessageRole.USER}
<ChatMessageUser
class={className}
* 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>
--- /dev/null
+<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 </span>
+ <span class="font-mono text-foreground/90 text-sm break-all" title={info.path}>
+ {info.display}
+ </span>
+ {/if}
+ </div>
+{/if}
--- /dev/null
+<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}
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';
<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}
<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';
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))
);
<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}
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,
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';
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'
</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}
</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;
}
<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';
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}>
<span class="font-mono">{fileGlobMeta.include}</span>
{/if}
<span class="text-muted-foreground"> in </span>
- <span class="font-mono">{fileGlobMeta.path}</span>
+ <span class="font-mono" title={fileGlobMeta.path}
+ >{abbreviateHome(fileGlobMeta.path, home)}</span
+ >
{/if}
{/snippet}
--- /dev/null
+<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 </span>
+ <span class="text-red-600 text-xs italic dark:text-red-400">- {infoMeta.errorMessage}</span
+ >
+ {:else if infoMeta.os || infoMeta.cwd}
+ <span class="text-foreground/80 text-sm font-medium">Runtime info </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>
<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';
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}>
<span class="text-muted-foreground">Search for </span>
<span class="font-mono">{grepMeta.pattern}</span>
<span class="text-muted-foreground"> in </span>
- <span class="font-mono">{grepMeta.path}</span>
+ <span class="font-mono" title={grepMeta.path}>{abbreviateHome(grepMeta.path, home)}</span>
{/if}
{/snippet}
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';
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}
*/
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
*
*/
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
FilePlus,
FileSearch,
FileText,
+ Info,
SearchCode,
Terminal
} from '@lucide/svelte';
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',
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';
export * from './uri-template';
export * from './url';
export * from './viewport';
+export * from './working-directory';
--- /dev/null
+/**
+ * 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]*?)\)/;
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',
--- /dev/null
+/**
+ * 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;
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';
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
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',
*/
export enum UrlProtocol {
DATA = 'data:',
+ FILE = 'file:',
HTTP = 'http:',
HTTPS = 'https:',
WEBSOCKET = 'ws:',
serverId: o.serverId,
enabled: o.enabled
}))
- : undefined
+ : undefined,
+ cwd: sourceConv.cwd
};
await db[IDXDB_TABLES.conversations].add(newConv);
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';
/**
* 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
});
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": "..."}`
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,
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';
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);
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;
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,
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');
timestamp: Date.now(),
toolCalls: '',
children: [],
- extra: extras
+ extra: extras,
+ isSynthetic
},
parentId
);
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) {
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,
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,
createToolResultMessage: async (
toolCallId: string,
content: string,
- extras?: DatabaseMessageExtra[]
+ extras?: DatabaseMessageExtra[],
+ toolCwd?: string
) => {
const msg = await DatabaseService.createMessageBranch(
{
role: MessageRole.TOOL,
content,
toolCallId,
+ toolCwd,
timestamp: Date.now(),
toolCalls: '',
children: [],
/** 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;
// 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;
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) {
this.activeMessages = [];
// reload defaults so new chats inherit persisted state
this.pendingReasoningEffort = ConversationsStore.loadReasoningEffortDefault();
+ this.pendingCwd = null;
}
/**
}
}
+ /**
+ * 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.
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;
/**
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';
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 {
return this._builtinTools;
}
+ get serverHome(): string | null {
+ return this._serverHome ?? null;
+ }
+
get mcpTools(): OpenAIToolDefinition[] {
return this.mcpEntries().map((e) => e.definition);
}
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();
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
createToolResultMessage?: (
toolCallId: string,
content: string,
- extras?: DatabaseMessageExtra[]
+ extras?: DatabaseMessageExtra[],
+ toolCwd?: string
) => Promise<DatabaseMessage>;
updateToolResultMessage?: (
messageId: string,
mcpServerOverrides?: McpServerOverride[];
thinkingEnabled?: boolean;
reasoningEffort?: ReasoningEffort;
+ cwd?: string;
forkedFromConversationId?: string;
pinned?: boolean;
}
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;
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
toolArgs: tc.function?.arguments,
toolResult: resultMsg?.content,
toolResultExtras: resultMsg?.extra,
+ toolCwd: resultMsg?.toolCwd,
toolCallId: tc.id
});
}
// 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,
--- /dev/null
+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;
+}
--- /dev/null
+/**
+ * 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;
+}
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
};
}
+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 }));
--- /dev/null
+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 }]);
+ });
+});