]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: CWD for agent (#26518)
authorAleksander Grygier <redacted>
Tue, 4 Aug 2026 17:05:48 +0000 (19:05 +0200)
committerGitHub <redacted>
Tue, 4 Aug 2026 17:05:48 +0000 (19:05 +0200)
* server : extend file_glob_search for UI pickers

* ui : add per-conversation working directory with picker

* ui : add path navigation and search scope to cwd picker

Treat path-like queries (starting with / or ~) as directory navigation
instead of glob-matching the whole query: search the parent for the last
segment, and descend into an exactly-typed directory by listing its
children. Show the effective search scope in the footer and auto-search
on open so the current directory and its siblings appear immediately.

Assisted-by: Claude
* db : persist per-call tool cwd on tool result messages

* ui : abbreviate tool paths under home with a tilde

* ui : show the per-call cwd on exec shell rows

* ui : clarify the synthetic cwd message for the model

* ui : reuse the trailing cwd row on a repeated pick

* ui : don't jump when a cwd row is injected mid-chat

* chore: Formatting

* refactor: Cleanup comments

* ui : unify working directory naming and add a synthetic-message flag

* ui : render synthetic cwd rows without a scroll jump

* ui : decouple the working directory picker into utils and sub-components

* ui : add get_info tool call block

* chore: Formatting

* refactor: Cleanup

* refactor: Cleanup

* refactor: Cleanup

* fix: UI

* server : harden file_glob_search listing (kind enum, timeout, symlink guard, absolute base)

* ui : use persisted isSynthetic flag for cwd rows, drop legacy formats

* ui : cache picker search, fail visibly on native resolve

* ui : escape glob metacharacters in picker search glob

* ui : simplify auto-scroll pin

* chore: Format

* fix: Use `SvelteMap`

* refactor: Post-review fixes

* ui: accept Windows roots in the working directory picker

recognize a drive root (C:) and a UNC share (//host/share) as path
navigation, alongside the POSIX root and ~, so a query like D:\repos
lists that directory instead of glob-matching it under the home dir

split below the root, so a bare drive resolves to its root rather than
to a drive-relative prefix

rewrite backslashes into forward slashes only when the query carries a
Windows root, since a backslash is a legal POSIX filename character

paths keep travelling with forward slashes, which is what the server
returns and what Windows accepts

---------

Co-authored-by: Pascal <redacted>
41 files changed:
tools/server/server-tools.cpp
tools/server/tests/unit/test_tools_builtin.py
tools/ui/src/app.d.ts
tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryResultsList.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageCwdChange.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSynthetic.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockFileGlobSearch.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetInfo.svelte [new file with mode: 0644]
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGrepSearch.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte
tools/ui/src/lib/components/app/chat/index.ts
tools/ui/src/lib/constants/built-in-tools.ts
tools/ui/src/lib/constants/index.ts
tools/ui/src/lib/constants/path-display.ts [new file with mode: 0644]
tools/ui/src/lib/constants/tools.ts
tools/ui/src/lib/constants/working-directory.ts [new file with mode: 0644]
tools/ui/src/lib/enums/index.ts
tools/ui/src/lib/enums/tools.enums.ts
tools/ui/src/lib/enums/ui.enums.ts
tools/ui/src/lib/services/database.service.ts
tools/ui/src/lib/services/tools.service.ts
tools/ui/src/lib/stores/agentic.svelte.ts
tools/ui/src/lib/stores/chat.svelte.ts
tools/ui/src/lib/stores/conversations.svelte.ts
tools/ui/src/lib/stores/tools.svelte.ts
tools/ui/src/lib/types/agentic.d.ts
tools/ui/src/lib/types/chat.d.ts
tools/ui/src/lib/types/database.d.ts
tools/ui/src/lib/utils/agentic.ts
tools/ui/src/lib/utils/index.ts
tools/ui/src/lib/utils/path-display.ts [new file with mode: 0644]
tools/ui/src/lib/utils/working-directory.ts [new file with mode: 0644]
tools/ui/tests/unit/tool-calls.test.ts
tools/ui/tests/unit/working-directory.test.ts [new file with mode: 0644]

index 984bb478ea23c8ccb46a649e31afc65e9e7a9705..e050d03b52886e7eea11d64d221a65aa4f88bb00 100644 (file)
 #include <ctime>
 #include <atomic>
 #include <cstring>
+#include <cstdlib>
 #include <algorithm>
 #include <unordered_set>
+#include <tuple>
 #include <functional>
 #include <memory>
 
@@ -34,7 +36,40 @@ json server_tool::to_json() const {
 }
 
 static constexpr size_t SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT = 8 * 1024 * 1024; // 8 MB
-static constexpr int SERVER_TOOL_GIT_LS_FILES_TIMEOUT = 15; // seconds
+// budget for one listing call, shared by the git and walker paths
+static constexpr int SERVER_TOOL_LIST_ENTRIES_TIMEOUT = 15; // seconds
+
+// entry kinds a directory listing may return
+enum class list_kind {
+    files, // regular files only
+    dirs,  // directories only
+    all,   // both
+};
+
+// home directory, read once at first use (getenv is not thread safe against setenv)
+static const std::string & home_dir() {
+    static const std::string home = [] {
+        const char * h = getenv("HOME");
+#ifdef _WIN32
+        if (h == nullptr) h = getenv("USERPROFILE");
+#endif
+        return h ? std::string(h) : std::string();
+    }();
+    return home;
+}
+
+static std::string expand_home(const std::string & path) {
+    if (path.empty() || path[0] != '~') return path;
+    if (path.size() > 1 && path[1] != '/' && path[1] != '\\') return path;
+    const std::string & home = home_dir();
+    if (home.empty()) return path;
+    return home + path.substr(1);
+}
+
+// depth of a '/'-separated relative path: "a/b/c" is 3
+static int entry_depth(const std::string & rel) {
+    return 1 + (int) std::count(rel.begin(), rel.end(), '/');
+}
 
 class tools_io {
 public:
@@ -51,8 +86,17 @@ public:
     virtual bool file_size(const std::string & path, uintmax_t & out_size) const = 0;
     virtual bool read_file(const std::string & path, std::string & out) const = 0;
     virtual bool write_file(const std::string & path, const std::string & content) const = 0;
-    // paths relative to `base`, '/'-separated; sets `err` if `base` isn't a directory
-    virtual std::vector<std::string> list_files(const std::string & base, std::string & err) const = 0;
+    // resolve `path` against the IO's working directory; absolute paths are returned unchanged
+    virtual std::string resolve(const std::string & path) const = 0;
+    struct list_entry {
+        std::string rel; // '/'-separated, relative to `base`
+        bool is_dir = false;
+    };
+    // entries relative to `base`; sets `err` if `base` isn't a directory
+    // max_depth == 0 means unlimited, 1 means direct children of `base` only
+    // `base` must already be resolved (absolute); `caller_path` is the path the
+    // caller passed, used only for error messages
+    virtual std::vector<list_entry> list_entries(const std::string & base, const std::string & caller_path, int max_depth, list_kind kind, std::string & err, bool & truncated) const = 0;
     // on_chunk, if set, is called with each chunk of output as it is read (before truncation cuts in);
     // returning false terminates the process early (e.g. the client disconnected)
     virtual exec_result run(
@@ -67,6 +111,22 @@ public:
     // cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
     explicit tools_io_basic(std::string cwd = "") : cwd(std::move(cwd)) {}
 
+    // expands a leading `~`, then resolves `path` against `cwd` (or the server
+    // working directory when `cwd` is unset); the result is always absolute
+    std::string resolve(const std::string & path) const override {
+        std::string p = expand_home(path);
+        if (fs::path(p).is_absolute()) {
+            return p;
+        }
+        if (cwd.empty()) {
+            std::error_code ec;
+            fs::path cur = fs::current_path(ec);
+            if (ec) return p;
+            return (cur / p).string();
+        }
+        return (fs::path(cwd) / p).string();
+    }
+
     bool is_directory(const std::string & path) const override {
         std::error_code ec;
         return fs::is_directory(resolve(path), ec) && !ec;
@@ -105,34 +165,41 @@ public:
         return (bool) f;
     }
 
-    std::vector<std::string> list_files(const std::string & base, std::string & err) const override {
+    std::vector<list_entry> list_entries(const std::string & base, const std::string & caller_path, int max_depth, list_kind kind, std::string & err, bool & truncated) const override {
         err.clear();
-        std::string abs_base = resolve(base);
-        if (!is_directory(base)) {
-            err = "path does not exist or is not a directory: " + base;
+        truncated = false;
+        std::error_code ec;
+        if (!fs::is_directory(base, ec) || ec) {
+            err = "path does not exist or is not a directory: " + caller_path;
             return {};
         }
 
-        auto res = run(
-            {"git", "-C", abs_base, "ls-files", "--cached", "--others", "--exclude-standard"},
-            SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_GIT_LS_FILES_TIMEOUT);
-
-        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(
@@ -211,14 +278,6 @@ public:
 private:
     std::string cwd;
 
-    // resolves `path` against `cwd` if `path` is relative and `cwd` is set; otherwise returns `path` unchanged
-    std::string resolve(const std::string & path) const {
-        if (cwd.empty() || fs::path(path).is_absolute()) {
-            return path;
-        }
-        return (fs::path(cwd) / path).string();
-    }
-
     static const std::unordered_set<std::string> & junk_dir_names() {
         static const std::unordered_set<std::string> names = {
             ".git", ".svn", ".hg", "node_modules", "__pycache__",
@@ -227,28 +286,50 @@ private:
         return names;
     }
 
-    std::vector<std::string> list_files_fallback(const std::string & base) const {
-        std::vector<std::string> result;
+    std::vector<list_entry> list_entries_fallback(const std::string & base, int max_depth, list_kind kind,
+                                                  std::chrono::steady_clock::time_point deadline, bool & truncated) const {
+        std::vector<list_entry> result;
         std::error_code ec;
 
-        std::vector<std::pair<fs::path, fs::path>> stack;
-        stack.emplace_back(fs::path(base), fs::path());
+        std::vector<std::tuple<fs::path, fs::path, int>> stack;
+        stack.emplace_back(fs::path(base), fs::path(), 0);
 
         while (!stack.empty()) {
-            auto [dir, rel_dir] = stack.back();
+            auto [dir, rel_dir, depth] = stack.back();
             stack.pop_back();
 
-            for (const auto & entry : fs::directory_iterator(dir, fs::directory_options::skip_permission_denied, ec)) {
+            // the throwing increment would escape the tool on a directory that
+            // goes away mid walk, so step the iterator explicitly
+            fs::directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec);
+            for (const fs::directory_iterator end; it != end; it.increment(ec)) {
                 if (ec) break;
+                if (std::chrono::steady_clock::now() >= deadline) {
+                    truncated = true;
+                    return result;
+                }
+                const fs::directory_entry & entry = *it;
                 std::string fname = entry.path().filename().string();
                 std::error_code tec;
                 if (entry.is_directory(tec)) {
+                    std::string rel = (rel_dir / fname).string();
+                    std::replace(rel.begin(), rel.end(), '\\', '/');
+                    if (kind == list_kind::dirs || kind == list_kind::all) {
+                        result.push_back({rel, true});
+                    }
+                    // junk directories stay selectable but are never walked: they
+                    // hold nothing worth searching and can be enormous
                     if (junk_dir_names().count(fname) > 0) continue;
-                    stack.emplace_back(entry.path(), rel_dir / fname);
+                    // do not descend into symlinks: a link can point back to an
+                    // ancestor and loop forever
+                    if (!entry.is_symlink(tec) && (max_depth == 0 || depth + 1 < max_depth)) {
+                        stack.emplace_back(entry.path(), rel_dir / fname, depth + 1);
+                    }
                 } else if (entry.is_regular_file(tec)) {
                     std::string rel = (rel_dir / fname).string();
                     std::replace(rel.begin(), rel.end(), '\\', '/');
-                    result.push_back(rel);
+                    if (kind == list_kind::files || kind == list_kind::all) {
+                        result.push_back({rel, false});
+                    }
                 }
             }
         }
@@ -363,6 +444,9 @@ struct server_tool_read_file : server_tool {
 //
 
 static constexpr size_t SERVER_TOOL_FILE_SEARCH_MAX_RESULTS = 100;
+static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_FILE = "file";
+static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_DIR  = "dir";
+static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_ALL  = "all";
 
 struct server_tool_file_glob_search : server_tool {
     server_tool_file_glob_search() {
@@ -382,13 +466,18 @@ struct server_tool_file_glob_search : server_tool {
                     "and common junk directories (.git, node_modules, build, dist, etc.) otherwise. "
                     "A pattern with no '/' (e.g. \"*.cpp\") matches the file's basename at any depth. "
                     "A pattern containing '/' matches the full relative path; unless already anchored with "
-                    "\"**/\" or a leading '/', it is automatically prefixed with \"**/\"."},
+                    "\"**/\" or a leading '/', it is automatically prefixed with \"**/\". "
+                    "Use type=\"dir\" or \"all\" to also list directories; directory entries are suffixed with '/' in the output. "
+                    "Note: directory listings do not apply .gitignore filtering."},
                 {"parameters", {
                     {"type", "object"},
                     {"properties", {
-                        {"path",    {{"type", "string"}, {"description", "Base directory to search in"}}},
-                        {"include", {{"type", "string"}, {"description", "Glob pattern for files to include (e.g. \"*.cpp\" or \"src/**/*.cpp\"). Default: **"}}},
-                        {"exclude", {{"type", "string"}, {"description", "Glob pattern for files to exclude"}}},
+                        {"path",      {{"type", "string"},  {"description", "Base directory to search in"}}},
+                        {"include",   {{"type", "string"},  {"description", "Glob pattern for files to include (e.g. \"*.cpp\" or \"src/**/*.cpp\"). Default: **"}}},
+                        {"exclude",   {{"type", "string"},  {"description", "Glob pattern for files to exclude"}}},
+                        {"type",      {{"type", "string"},  {"description", "Entry type to return: \"file\" (default), \"dir\" or \"all\""}}},
+                        {"max_depth", {{"type", "integer"}, {"description", "Maximum depth to descend into subdirectories (default: 0 = unlimited; 1 = direct children only)"}}},
+                        {"limit",     {{"type", "integer"}, {"description", string_format("Maximum number of results to return (default %zu; values below 1 fall back to the default)", SERVER_TOOL_FILE_SEARCH_MAX_RESULTS)}}},
                     }},
                     {"required", json::array({"path"})},
                 }},
@@ -397,30 +486,56 @@ struct server_tool_file_glob_search : server_tool {
     }
 
     json invoke(json params, server_tool::stream *) const override {
-        std::string base    = params.at("path").get<std::string>();
-        std::string include = json_value(params, "include", std::string("**"));
-        std::string exclude = json_value(params, "exclude", std::string(""));
-
         auto io = make_tools_io(params);
+
+        std::string base      = io->resolve(params.at("path").get<std::string>());
+        // normalize to forward slashes so the web UI (which assumes '/') can
+        // join the relative entries into absolute paths on Windows too
+        std::replace(base.begin(), base.end(), '\\', '/');
+        std::string include   = json_value(params, "include", std::string("**"));
+        std::string exclude   = json_value(params, "exclude", std::string(""));
+        std::string type      = json_value(params, "type",    std::string("file"));
+        int         max_depth = std::max(0, json_value(params, "max_depth", 0));
+        int         limit     = json_value(params, "limit", (int) SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
+        if (limit < 1) limit = SERVER_TOOL_FILE_SEARCH_MAX_RESULTS;
+        limit = std::min(limit, (int) SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
+
+        list_kind kind;
+        if (type == SERVER_TOOL_FILE_SEARCH_TYPE_FILE) {
+            kind = list_kind::files;
+        } else if (type == SERVER_TOOL_FILE_SEARCH_TYPE_DIR) {
+            kind = list_kind::dirs;
+        } else if (type == SERVER_TOOL_FILE_SEARCH_TYPE_ALL) {
+            kind = list_kind::all;
+        } else {
+            return {{"error", "invalid type: " + type + " (expected \"file\", \"dir\" or \"all\")"}};
+        }
+
         std::string err;
-        auto files = io->list_files(base, err);
+        bool truncated = false;
+        auto entries = io->list_entries(base, params.at("path").get<std::string>(), max_depth, kind, err, truncated);
         if (!err.empty()) {
             return {{"error", err}};
         }
 
-        std::vector<std::string> matches;
-        for (const auto & rel : files) {
-            if (!path_glob_match(include, rel)) continue;
-            if (!exclude.empty() && path_glob_match(exclude, rel)) continue;
-            matches.push_back(rel);
+        std::vector<tools_io::list_entry> matches;
+        for (const auto & entry : entries) {
+            if (!path_glob_match(include, entry.rel)) continue;
+            if (!exclude.empty() && path_glob_match(exclude, entry.rel)) continue;
+            matches.push_back(entry);
         }
 
         size_t total = matches.size();
-        size_t shown = std::min(total, SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
+        size_t shown = std::min(total, (size_t) limit);
 
         std::ostringstream output_text;
+        json entries_json = json::array();
         for (size_t i = 0; i < shown; i++) {
-            output_text << matches[i] << "\n";
+            output_text << matches[i].rel << (matches[i].is_dir ? "/" : "") << "\n";
+            entries_json.push_back({
+                {"path", matches[i].rel},
+                {"type", matches[i].is_dir ? "dir" : "file"},
+            });
         }
 
         output_text << "\n---\nTotal matches: " << total << "\n";
@@ -429,8 +544,16 @@ struct server_tool_file_glob_search : server_tool {
                 "[%zu results limit reached (%zu total matches). Refine the glob pattern to narrow the search.]\n",
                 shown, total);
         }
+        if (truncated) {
+            output_text << "[search timed out, results truncated]\n";
+        }
 
-        return {{"plain_text_response", output_text.str()}};
+        // `base` is always absolute (resolve falls back to the server cwd), so
+        // API clients (e.g. the web UI picker) can join the relative entries
+        // into absolute paths. `plain_text_response` is what the model sees;
+        // `entries` is the same data as structured JSON for the UI picker,
+        // which reads `entries`/`base` instead of re-parsing the text.
+        return {{"plain_text_response", output_text.str()}, {"entries", entries_json}, {"base", base}};
     }
 };
 
@@ -513,18 +636,20 @@ struct server_tool_grep_search : server_tool {
         // collect (absolute_path, display_path) pairs to search
         std::vector<std::pair<std::string, std::string>> files;
 
-        if (io->is_regular_file(path)) {
-            files.emplace_back(path, path);
-        } else if (io->is_directory(path)) {
+        const std::string abs_path = io->resolve(path);
+        if (io->is_regular_file(abs_path)) {
+            files.emplace_back(abs_path, path);
+        } else if (io->is_directory(abs_path)) {
             std::string err;
-            auto candidates = io->list_files(path, err);
+            bool truncated = false;
+            auto candidates = io->list_entries(abs_path, path, 0, list_kind::files, err, truncated);
             if (!err.empty()) {
                 return {{"error", err}};
             }
-            for (const auto & rel : candidates) {
-                if (!path_glob_match(include, rel)) continue;
-                if (!exclude.empty() && path_glob_match(exclude, rel)) continue;
-                files.emplace_back((fs::path(path) / rel).string(), rel);
+            for (const auto & entry : candidates) {
+                if (!path_glob_match(include, entry.rel)) continue;
+                if (!exclude.empty() && path_glob_match(exclude, entry.rel)) continue;
+                files.emplace_back((fs::path(abs_path) / entry.rel).string(), entry.rel);
             }
         } else {
             return {{"error", "path does not exist: " + path}};
@@ -1094,6 +1219,9 @@ struct server_tool_get_datetime : server_tool {
 // get_info: returns runtime info (OS name/version and cwd)
 //
 
+static constexpr size_t SERVER_TOOL_GET_INFO_MAX_OUTPUT = 4096;
+static constexpr int    SERVER_TOOL_GET_INFO_TIMEOUT    = 5; // seconds
+
 struct server_tool_get_info : server_tool {
     server_tool_get_info() {
         name = "get_info";
@@ -1119,9 +1247,9 @@ struct server_tool_get_info : server_tool {
         auto io = make_tools_io(params);
 
 #ifdef _WIN32
-        auto res = io->run({"cmd", "/c", "ver"}, 4096, 5);
+        auto res = io->run({"cmd", "/c", "ver"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
 #else
-        auto res = io->run({"uname", "-a"}, 4096, 5);
+        auto res = io->run({"uname", "-a"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
 #endif
         // "ver" prints a blank line before the version, so the output is stripped on both ends;
         // a failed spawn or a timeout leaves a diagnostic in res.output, which is not an OS name
index fb194cac669ac091a552c7b58e8b8cd61deef331..e713758d91df37a962adb2972bf30419e7807359 100755 (executable)
@@ -164,3 +164,101 @@ def test_tools_builtin_edit_file_rejects_overlapping_edits():
     finally:
         if os.path.exists(log_path):
             os.remove(log_path)
+
+
+def test_tools_builtin_file_glob_search_type_dir(tmp_path):
+    global server
+    server.start()
+
+    (tmp_path / "project-alpha" / "src").mkdir(parents=True)
+    (tmp_path / "project-alpha" / "README.md").write_text("alpha")
+    (tmp_path / "project-alpha" / "src" / "main.cpp").write_text("int main() {}")
+    (tmp_path / "project-beta").mkdir()
+    (tmp_path / "project-beta" / "notes.txt").write_text("beta")
+
+    res = call_tool("file_glob_search", {"path": str(tmp_path), "type": "dir"})
+    text = res["plain_text_response"]
+    assert "project-alpha/" in text
+    assert "project-beta/" in text
+    assert "project-alpha/src/" in text
+    assert "README.md" not in text
+    types = {e["path"]: e["type"] for e in res["entries"]}
+    assert types["project-alpha"] == "dir"
+    assert types["project-alpha/src"] == "dir"
+
+    res_all = call_tool("file_glob_search", {"path": str(tmp_path), "type": "all", "include": "*proj*"})
+    paths = [e["path"] for e in res_all["entries"]]
+    assert "project-alpha" in paths
+    assert "project-beta" in paths
+
+
+def test_tools_builtin_file_glob_search_max_depth_and_limit(tmp_path):
+    global server
+    server.start()
+
+    (tmp_path / "a" / "b" / "c").mkdir(parents=True)
+    (tmp_path / "top.txt").write_text("top")
+    (tmp_path / "a" / "mid.txt").write_text("mid")
+    (tmp_path / "a" / "b" / "deep.txt").write_text("deep")
+
+    res = call_tool("file_glob_search", {"path": str(tmp_path), "max_depth": 1})
+    assert "top.txt" in res["plain_text_response"]
+    assert "mid.txt" not in res["plain_text_response"]
+
+    res = call_tool("file_glob_search", {"path": str(tmp_path), "max_depth": 2})
+    assert "mid.txt" in res["plain_text_response"]
+    assert "deep.txt" not in res["plain_text_response"]
+
+    res = call_tool("file_glob_search", {"path": str(tmp_path), "limit": 1})
+    assert len(res["entries"]) == 1
+    assert "Total matches: 3" in res["plain_text_response"]
+
+
+def test_tools_builtin_file_glob_search_rejects_invalid_type(tmp_path):
+    global server
+    server.start()
+
+    err = call_tool_expect_error("file_glob_search", {"path": str(tmp_path), "type": "bogus"})
+    assert "invalid type" in err
+
+
+def test_tools_builtin_cwd_header_overrides_model_param(tmp_path):
+    global server
+    server.start()
+
+    workdir = tmp_path / "workdir"
+    workdir.mkdir()
+    (workdir / "marker.txt").write_text("marker")
+
+    # a model-provided "cwd" in the params is overridden by the x-tool-cwd header
+    res = call_tool("read_file", {"path": "marker.txt", "cwd": "/definitely/not/a/real/path"},
+                    headers={"x-tool-cwd": str(workdir)})
+    assert "marker" in res["plain_text_response"]
+
+
+def test_tools_builtin_cwd_relative_paths(tmp_path):
+    global server
+    server.start()
+
+    workdir = tmp_path / "workdir"
+    workdir.mkdir()
+    (workdir / "rel.txt").write_text("relative-content")
+
+    headers = {"x-tool-cwd": str(workdir)}
+
+    # relative paths in file tools resolve against the header cwd
+    res = call_tool("read_file", {"path": "rel.txt"}, headers=headers)
+    assert "relative-content" in res["plain_text_response"]
+
+    res = call_tool("write_file", {"path": "sub/out.txt", "content": "written"}, headers=headers)
+    assert (workdir / "sub" / "out.txt").read_text() == "written"
+
+    res = call_tool("file_glob_search", {"path": ".", "include": "*.txt"}, headers=headers)
+    assert "rel.txt" in res["plain_text_response"]
+
+    # absolute paths are unaffected by the cwd
+    other = tmp_path / "other"
+    other.mkdir()
+    (other / "abs.txt").write_text("absolute-content")
+    res = call_tool("read_file", {"path": str(other / "abs.txt")}, headers=headers)
+    assert "absolute-content" in res["plain_text_response"]
index 5264e5cc4d72b8e00f83fc221b73a1e78a1a1656..b9484d95031cb07c3ed8878ca517403b83bd8ec8 100644 (file)
@@ -142,5 +142,14 @@ declare global {
        interface Window {
                idxThemeStyle?: number;
                idxCodeBlock?: number;
+
+               // File System Access API - missing from older DOM lib versions.
+               // Used by ChatFormWorkingDirectory's native folder picker. Feature availability
+               // is gated at runtime via `typeof window.showDirectoryPicker === 'function'`.
+               showDirectoryPicker: (options?: {
+                       id?: string;
+                       mode?: 'read' | 'readwrite';
+                       startIn?: FileSystemHandle | string;
+               }) => Promise<FileSystemDirectoryHandle>;
        }
 }
index 85683908cc719a998067d6526bedf8253fc2f92f..105e414fe78f424429a5adf94aca3211eed2e606 100644 (file)
@@ -6,6 +6,7 @@
                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
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte
new file mode 100644 (file)
index 0000000..6c49d49
--- /dev/null
@@ -0,0 +1,479 @@
+<script lang="ts">
+       import { FolderOpen } from '@lucide/svelte';
+       import { untrack } from 'svelte';
+       import { SvelteMap } from 'svelte/reactivity';
+       import { ToolsService } from '$lib/services/tools.service';
+       import { toolsStore } from '$lib/stores/tools.svelte';
+       import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
+       import {
+               abbreviateHome,
+               buildCaseInsensitiveGlob,
+               joinPath,
+               lastPathSegment,
+               rankEntries,
+               splitPathQuery,
+               type GlobEntry
+       } from '$lib/utils';
+       import { debounce } from '$lib/utils/debounce';
+       import * as Popover from '$lib/components/ui/popover';
+       import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
+       import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte';
+       import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.svelte';
+       import {
+               DEFAULT_MOBILE_BREAKPOINT,
+               GLOB_WILDCARD,
+               HOME_TILDE,
+               MAX_RESULTS_SHOWN,
+               NATIVE_LIMIT,
+               NATIVE_MAX_DEPTH,
+               PATH_NAV_MAX_DEPTH,
+               SEARCH_DEBOUNCE_MS,
+               SEARCH_LIMIT,
+               SEARCH_MAX_DEPTH
+       } from '$lib/constants';
+
+       // Microtask delay so the popover's focus scope tears down first.
+       const FOCUS_DELAY_MS = 0;
+
+       interface Props {
+               class?: string;
+               disabled?: boolean;
+               directory?: string | null;
+               onChange?: (directory: string | null) => void;
+               /**
+                * Lets the host refocus the chat input so typing can resume without
+                * an extra click after the popover closes.
+                */
+               onClose?: () => void;
+       }
+
+       let {
+               class: className = '',
+               disabled = false,
+               directory = $bindable(null),
+               onChange,
+               onClose
+       }: Props = $props();
+
+       // File System Access API is opt-in: when available (Chrome / Edge / Opera) the popover
+       // exposes a "Browse" button that opens the native folder picker. When unavailable the
+       // popover still works via the text input - no alerts, no upload semantics.
+       const pickerSupported =
+               typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function';
+
+       // Popover open state; the element handles outside-click and Escape.
+       let isOpen = $state(false);
+       let inputValue = $state('');
+       let searchInputRef: HTMLInputElement | null = $state(null);
+
+       let queryResults = $state<string[]>([]);
+       let isSearching = $state(false);
+       let searchError = $state<string | null>(null);
+       let hoveredIndex = $state(-1);
+       // Bumped only by ArrowUp/ArrowDown handlers; the list scrolls the
+       // highlighted row into view only via this trigger, never on hover.
+       let scrollTrigger = $state(0);
+       let listContainer = $state<HTMLDivElement | null>(null);
+
+       // Absolute home directory on the server, resolved once per session by
+       // the tools store. Anchors both the search scope and the chip's `~`
+       // abbreviation.
+       let homeBase = $derived(toolsStore.serverHome);
+
+       // AbortController + sequence counter to discard stale responses when the user
+       // keeps typing; a newer call aborts the previous one. The sequence counter
+       // also covers the gap between abort and the catch handler.
+       let searchController: AbortController | null = null;
+       let searchSeq = 0;
+
+       // Cache of the last file_glob_search result per (parent, include, max_depth),
+       // so repeated queries in the same directory don't re-walk the tree. Entries
+       // expire after a short TTL.
+       const SEARCH_CACHE_TTL_MS = 2000;
+       const searchCache = new SvelteMap<string, { results: GlobEntry[]; base: string; at: number }>();
+
+       const runSearch = debounce((query: string) => {
+               void doSearch(query);
+       }, SEARCH_DEBOUNCE_MS);
+
+       // Resolve home eagerly on mount so the chip can abbreviate before the
+       // user opens the picker. resolveServerHome() is cached, so repeat calls
+       // (e.g. from handleOpenChange) are no-ops.
+       $effect(() => {
+               if (typeof window === 'undefined') return;
+               void toolsStore.resolveServerHome();
+       });
+
+       // Auto-focus the search input when the popover opens.
+       // HTML `autofocus` is unreliable on dynamically shown elements, so we
+       // use a microtask (0ms setTimeout) after the effect flushes.
+       $effect(() => {
+               if (!isOpen) return;
+               setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
+       });
+
+       let lastScrollTrigger: number | null = null;
+
+       // hoveredIndex/queryResults are untracked so hover and result replacement
+       // never re-fire the scroll; keyboard nav is the only path that bumps the trigger
+       $effect(() => {
+               if (scrollTrigger === lastScrollTrigger) return;
+               lastScrollTrigger = scrollTrigger;
+               untrack(() => {
+                       if (!listContainer) return;
+                       if (hoveredIndex < 0 || hoveredIndex >= queryResults.length) return;
+                       const selectedElement = listContainer.querySelector(
+                               `[data-result-index="${hoveredIndex}"]`
+                       ) as HTMLElement | null;
+                       selectedElement?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
+               });
+       });
+
+       function cancelSearch() {
+               searchController?.abort();
+               searchSeq++;
+               isSearching = false;
+       }
+
+       // Effective directory the current search runs against (shown in the
+       // footer); updated by doSearch, including when an exactly-typed
+       // directory is "entered".
+       let searchScope = $state(HOME_TILDE);
+
+       // Runs a directory listing through the cache, so a repeated query in the
+       // same directory does not re-walk the tree on the server.
+       async function searchDirs(
+               path: string,
+               include: string,
+               maxDepth: number,
+               signal: AbortSignal
+       ): Promise<{ base: string; entries: GlobEntry[]; error?: string }> {
+               const key = `${path}\u0000${include}\u0000${maxDepth}`;
+               const cached = searchCache.get(key);
+               if (cached && Date.now() - cached.at < SEARCH_CACHE_TTL_MS) {
+                       return { base: cached.base, entries: cached.results };
+               }
+               const res = await ToolsService.executeToolRaw(
+                       BuiltInTool.FILE_GLOB_SEARCH,
+                       { path, type: GlobSearchType.DIR, include, max_depth: maxDepth, limit: SEARCH_LIMIT },
+                       signal
+               );
+               if (typeof res.error === 'string') return { base: '', entries: [], error: res.error };
+               const base = typeof res.base === 'string' ? res.base : '';
+               const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
+               searchCache.set(key, { results: entries, base, at: Date.now() });
+               return { base, entries };
+       }
+
+       async function doSearch(query: string) {
+               const trimmed = query.trim();
+               if (!trimmed) {
+                       queryResults = [];
+                       searchError = null;
+                       isSearching = false;
+                       hoveredIndex = -1;
+                       searchScope = homeBase ?? HOME_TILDE;
+                       return;
+               }
+
+               cancelSearch();
+               const controller = new AbortController();
+               searchController = controller;
+               const mySeq = ++searchSeq;
+
+               const pathQuery = splitPathQuery(trimmed);
+
+               isSearching = true;
+               try {
+                       // A generous limit is requested because ranking happens
+                       // client-side; only the top 20 are shown.
+                       const searchPath = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
+                       const include = pathQuery
+                               ? pathQuery.last
+                                       ? buildCaseInsensitiveGlob(pathQuery.last)
+                                       : GLOB_WILDCARD
+                               : buildCaseInsensitiveGlob(trimmed);
+                       const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : SEARCH_MAX_DEPTH;
+                       const res = await searchDirs(searchPath, include, maxDepth, controller.signal);
+                       if (mySeq !== searchSeq) return;
+                       if (res.error) {
+                               queryResults = [];
+                               hoveredIndex = -1;
+                               searchError = res.error;
+                               return;
+                       }
+                       const { base, entries } = res;
+                       const ranked = rankEntries(entries, pathQuery?.last ?? trimmed);
+                       let results = ranked.map((e) => joinPath(base, e.path));
+                       searchScope = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
+
+                       // An exactly-typed directory is "entered": list its children too,
+                       // so path navigation doesn't require a trailing slash.
+                       const last = pathQuery?.last;
+                       const exact = last
+                               ? ranked.find((e) => lastPathSegment(e.path).toLowerCase() === last.toLowerCase())
+                               : undefined;
+                       if (exact) {
+                               const exactDir = joinPath(base, exact.path);
+                               const childRes = await searchDirs(
+                                       exactDir,
+                                       GLOB_WILDCARD,
+                                       PATH_NAV_MAX_DEPTH,
+                                       controller.signal
+                               );
+                               if (mySeq !== searchSeq) return;
+                               if (!childRes.error) {
+                                       const children = childRes.entries
+                                               .map((e) => joinPath(childRes.base, e.path))
+                                               .sort((a, b) => a.localeCompare(b));
+                                       results = [...results, ...children];
+                                       searchScope = exactDir;
+                               }
+                       }
+
+                       queryResults = results.slice(0, MAX_RESULTS_SHOWN);
+                       hoveredIndex = queryResults.length > 0 ? 0 : -1;
+                       // new results: scroll the list back to the top (first item is hovered)
+                       if (hoveredIndex === 0) scrollTrigger++;
+                       searchError = null;
+               } catch (err) {
+                       if (mySeq !== searchSeq) return;
+                       queryResults = [];
+                       hoveredIndex = -1;
+                       if (controller.signal.aborted) return;
+                       searchError = err instanceof Error ? err.message : String(err);
+               } finally {
+                       if (mySeq === searchSeq) isSearching = false;
+               }
+       }
+
+       // Single funnel for every local close so the host refocus fires
+       // regardless of which commit/dismiss path ended the interaction.
+       function closePicker() {
+               isOpen = false;
+               onClose?.();
+       }
+
+       function commit(path: string) {
+               directory = path;
+               onChange?.(path);
+               closePicker();
+       }
+
+       function setDirectory(value: string) {
+               const trimmed = value.trim();
+               if (!trimmed) return;
+               directory = trimmed;
+               onChange?.(trimmed);
+       }
+
+       // Resolve a folder name picked via the browser-native picker (which exposes
+       // only the leaf name) to a server-side absolute path. Returns null when the
+       // server cannot locate a matching directory, so the caller can fail visibly
+       // instead of committing a bare leaf name that would resolve against the
+       // server process working directory.
+       async function resolveNativeName(name: string): Promise<string | null> {
+               try {
+                       const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
+                               path: homeBase ?? HOME_TILDE,
+                               type: GlobSearchType.DIR,
+                               include: buildCaseInsensitiveGlob(name),
+                               max_depth: NATIVE_MAX_DEPTH,
+                               limit: NATIVE_LIMIT
+                       });
+                       const base = typeof res.base === 'string' ? res.base : '';
+                       const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
+                       const match = entries.find(
+                               (e) => lastPathSegment(e.path).toLowerCase() === name.toLowerCase()
+                       );
+                       return match ? joinPath(base, match.path) : null;
+               } catch {
+                       return null;
+               }
+       }
+
+       async function browseNative() {
+               if (disabled || !window.showDirectoryPicker) return;
+               try {
+                       const handle = await window.showDirectoryPicker();
+                       const path = await resolveNativeName(handle.name);
+                       if (path) {
+                               setDirectory(path);
+                               closePicker();
+                       } else {
+                               // keep the previous cwd and fail visibly instead of committing a
+                               // bare leaf name that would resolve against the server cwd
+                               searchError = `Could not resolve "${handle.name}" to a server path`;
+                       }
+               } catch (err) {
+                       // user cancelled - silently ignore; other errors are logged
+                       if (err instanceof DOMException && err.name === 'AbortError') return;
+                       console.error('[ChatFormWorkingDirectory] showDirectoryPicker failed:', err);
+               }
+       }
+
+       function handleSubmit() {
+               const value = inputValue.trim();
+               if (!value) {
+                       closePicker();
+                       return;
+               }
+               setDirectory(value);
+               closePicker();
+       }
+
+       function handleKeydown(event: KeyboardEvent) {
+               if (event.key === KeyboardKey.ENTER) {
+                       event.preventDefault();
+                       // Commit the highlighted result, falling back to the raw input
+                       // only when the query returned no matches.
+                       if (hoveredIndex >= 0 && queryResults[hoveredIndex]) {
+                               commit(queryResults[hoveredIndex]);
+                       } else if (queryResults.length === 0) {
+                               handleSubmit();
+                       }
+               } else if (event.key === KeyboardKey.ARROW_DOWN) {
+                       if (queryResults.length > 0) {
+                               event.preventDefault();
+                               hoveredIndex = (hoveredIndex + 1) % queryResults.length;
+                               scrollTrigger++;
+                       }
+               } else if (event.key === KeyboardKey.ARROW_UP) {
+                       if (queryResults.length > 0) {
+                               event.preventDefault();
+                               hoveredIndex = hoveredIndex <= 0 ? queryResults.length - 1 : hoveredIndex - 1;
+                               scrollTrigger++;
+                       }
+               }
+       }
+
+       function handleInputInput(value: string) {
+               hoveredIndex = -1;
+               if (value.trim().length > 0) {
+                       runSearch(value);
+               }
+       }
+
+       function clearDirectory(event?: MouseEvent) {
+               // Stop the click from bubbling into the popover trigger and re-opening
+               // the picker on top of the now-cleared state.
+               event?.stopPropagation();
+               event?.preventDefault();
+               directory = null;
+               onChange?.(null);
+               closePicker();
+       }
+
+       // The chip is always visible; the X clears the directory (no-op when
+       // already empty).
+       function handleDismiss(event?: MouseEvent) {
+               event?.stopPropagation();
+               event?.preventDefault();
+               if (directory) {
+                       clearDirectory(event);
+               }
+       }
+
+       function handleOpenChange(open: boolean) {
+               isOpen = open;
+               if (open) {
+                       // Seed the search field with the current path so the user can refine it
+                       // (or hit Enter to confirm / clear via the X icon).
+                       inputValue = directory ?? '';
+                       hoveredIndex = -1;
+                       queryResults = [];
+                       searchError = null;
+                       void toolsStore.resolveServerHome();
+                       searchScope = homeBase ?? HOME_TILDE;
+                       if (inputValue.trim()) runSearch(inputValue);
+               } else {
+                       cancelSearch();
+                       // bits-ui-initiated close (Escape on the content, outside-click,
+                       // trigger toggle) - the only path that bypasses closePicker().
+                       onClose?.();
+               }
+       }
+
+       // Tooltips only on wider viewports - hover surfaces get in the way on
+       // touch / narrow layouts. Mirrors the gate used in ActionIcon.
+       let innerWidth = $state(0);
+       const showTooltip = $derived(innerWidth > DEFAULT_MOBILE_BREAKPOINT);
+</script>
+
+<div
+       class={[
+               'justify-self-start flex min-w-0 w-auto items-center gap-1 mt-1.5 py-1 px-2 backdrop-blur-2xl rounded-md',
+               className,
+               isOpen && 'w-full'
+       ]}
+>
+       <Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
+               <Popover.Trigger {disabled} class="flex justify-start">
+                       <ChatFormWorkingDirectoryChip
+                               {directory}
+                               {homeBase}
+                               {disabled}
+                               {showTooltip}
+                               onClear={handleDismiss}
+                       />
+               </Popover.Trigger>
+
+               <Popover.Content
+                       side="top"
+                       align="start"
+                       sideOffset={4}
+                       class="md:max-w-3xl w-[calc(100vw-1rem)] rounded-xl border-border/50 p-0 shadow-xl md:-translate-2!"
+                       onkeydown={handleKeydown}
+                       onOpenAutoFocus={(event) => event.preventDefault()}
+               >
+                       <div class="p-2 min-h-28 flex flex-col justify-between">
+                               <SearchInput
+                                       bind:ref={searchInputRef}
+                                       bind:value={inputValue}
+                                       placeholder="Choose working directory"
+                                       onInput={handleInputInput}
+                                       onClose={closePicker}
+                                       class="w-full"
+                               />
+
+                               {#if inputValue.trim() && (isSearching || queryResults.length > 0 || searchError)}
+                                       <ChatFormWorkingDirectoryResultsList
+                                               results={queryResults}
+                                               {hoveredIndex}
+                                               {isSearching}
+                                               error={searchError}
+                                               rawQuery={inputValue}
+                                               bind:container={listContainer}
+                                               onCommit={commit}
+                                               onHover={(index) => (hoveredIndex = index)}
+                                       />
+                               {/if}
+
+                               {#if pickerSupported}
+                                       <button
+                                               type="button"
+                                               class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
+                                               onclick={browseNative}
+                                       >
+                                               <FolderOpen class="size-4 shrink-0 text-muted-foreground" />
+                                               <span>Browse</span>
+                                       </button>
+                               {/if}
+
+                               {#if homeBase}
+                                       <div class="-mx-2 my-1 h-px bg-border/20" aria-hidden="true"></div>
+
+                                       <span class="px-2 py-2 font-mono text-[10px]">
+                                               Searching in:
+
+                                               <span class="truncate text-muted-foreground/70" title={searchScope}
+                                                       >{abbreviateHome(searchScope, homeBase)}</span
+                                               >
+                                       </span>
+                               {/if}
+                       </div>
+               </Popover.Content>
+       </Popover.Root>
+</div>
+
+<svelte:window bind:innerWidth />
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte
new file mode 100644 (file)
index 0000000..4f8d0f7
--- /dev/null
@@ -0,0 +1,69 @@
+<script lang="ts">
+       import { Folder, X } from '@lucide/svelte';
+       import { abbreviateWorkingDir } from '$lib/utils';
+       import * as Tooltip from '$lib/components/ui/tooltip';
+       import { ActionIcon } from '$lib/components/app/actions';
+
+       interface Props {
+               directory?: string | null;
+               homeBase?: string | null;
+               disabled?: boolean;
+               showTooltip?: boolean;
+               onClear?: (event?: MouseEvent) => void;
+       }
+
+       let {
+               directory = null,
+               homeBase = null,
+               disabled = false,
+               showTooltip = false,
+               onClear
+       }: Props = $props();
+
+       const displayLabel = $derived(
+               directory ? abbreviateWorkingDir(directory, homeBase) : 'Select working directory'
+       );
+       // Full path surface: hover the abbreviated label to recall the exact directory.
+       const displayLabelTitle = $derived(directory ?? '');
+</script>
+
+<span
+       class="text-muted-foreground inline-flex items-center gap-1 text-xs group"
+       class:text-foreground={directory}
+>
+       <div class="flex min-w-0 items-center gap-1 cursor-pointer">
+               <Folder class="w-3.5 h-3.5" />
+
+               {#if showTooltip && displayLabelTitle}
+                       <Tooltip.Root>
+                               <Tooltip.Trigger>
+                                       {#snippet child({ props })}
+                                               <span {...props} class="max-w-64 truncate">{displayLabel}</span>
+                                       {/snippet}
+                               </Tooltip.Trigger>
+                               <Tooltip.Content>
+                                       <p>{displayLabelTitle}</p>
+                               </Tooltip.Content>
+                       </Tooltip.Root>
+               {:else}
+                       <span class="max-w-64 truncate">{displayLabel}</span>
+               {/if}
+       </div>
+
+       {#if directory}
+               <div
+                       class="w-0 overflow-hidden opacity-0 transition-[width,opacity] duration-200 ease-out group-hover:w-auto group-hover:opacity-100"
+               >
+                       <ActionIcon
+                               icon={X}
+                               tooltip="Reset working directory"
+                               ariaLabel="Reset working directory"
+                               {disabled}
+                               onclick={onClear}
+                               iconSize="h-3 w-3"
+                               stopPropagationOnClick
+                               class="!h-4 !w-4 shrink-0 text-muted-foreground hover:text-foreground"
+                       />
+               </div>
+       {/if}
+</span>
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryResultsList.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryResultsList.svelte
new file mode 100644 (file)
index 0000000..d62eb88
--- /dev/null
@@ -0,0 +1,72 @@
+<script lang="ts">
+       import { Folder } from '@lucide/svelte';
+       import { fly } from 'svelte/transition';
+       import { highlightMatch } from '$lib/utils';
+       import { cn } from '$lib/components/ui/utils';
+
+       // Fly-in transition for the results list.
+       const FLY_Y_PX = -4;
+       const FLY_DURATION_MS = 100;
+
+       interface Props {
+               results: string[];
+               hoveredIndex: number;
+               isSearching: boolean;
+               error: string | null;
+               rawQuery: string;
+               container?: HTMLDivElement | null;
+               onCommit?: (path: string) => void;
+               onHover?: (index: number) => void;
+       }
+
+       let {
+               results,
+               hoveredIndex,
+               isSearching,
+               error,
+               rawQuery,
+               container = $bindable(null),
+               onCommit,
+               onHover
+       }: Props = $props();
+</script>
+
+<div
+       bind:this={container}
+       class="max-h-48 overflow-y-auto py-2"
+       transition:fly={{ y: FLY_Y_PX, duration: FLY_DURATION_MS }}
+>
+       {#if isSearching && results.length === 0}
+               <div class="px-2 py-1.5 text-sm text-muted-foreground">Searching...</div>
+       {:else if error}
+               <div class="px-2 py-1.5 text-sm text-destructive">{error}</div>
+       {:else if results.length === 0}
+               <div class="px-2 py-1.5 text-sm text-muted-foreground">No matching folders</div>
+       {:else}
+               {#each results as path, index (path)}
+                       <button
+                               type="button"
+                               data-result-index={index}
+                               data-highlighted={index === hoveredIndex ? '' : undefined}
+                               class={cn(
+                                       'relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground'
+                               )}
+                               onclick={() => onCommit?.(path)}
+                               onmouseenter={() => onHover?.(index)}
+                       >
+                               <Folder class="size-4 shrink-0 text-muted-foreground" />
+                               <span class="min-w-0 flex-1 truncate font-mono text-left">
+                                       {#each highlightMatch(path, rawQuery.trim()) as seg, segIndex (segIndex)}
+                                               {#if seg.match}
+                                                       <mark class="rounded bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-500/30"
+                                                               >{seg.text}</mark
+                                                       >
+                                               {:else}
+                                                       {seg.text}
+                                               {/if}
+                                       {/each}
+                               </span>
+                       </button>
+               {/each}
+       {/if}
+</div>
index b8068f790734b37992a87f55cee88a6fc0d04af2..afe90f66fe258cceec656b6276bc1c97cf6253c0 100644 (file)
@@ -12,6 +12,7 @@
                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>
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageCwdChange.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageCwdChange.svelte
new file mode 100644 (file)
index 0000000..0b01330
--- /dev/null
@@ -0,0 +1,31 @@
+<script lang="ts">
+       import { Folder, FolderX } from '@lucide/svelte';
+       import { parseCwdMessage } from '$lib/utils';
+       import type { DatabaseMessage } from '$lib/types';
+
+       interface Props {
+               class?: string;
+               message: DatabaseMessage;
+       }
+
+       let { class: className = '', message }: Props = $props();
+
+       // Parse the synthetic message content in the UI so the row reuses the
+       // exact same text the model saw, including any guidance suffix.
+       let info = $derived(parseCwdMessage(message.content));
+</script>
+
+{#if info}
+       <div class="text-muted-foreground flex items-center gap-2 py-1.5 {className}">
+               {#if info.path === null}
+                       <FolderX class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
+                       <span class="text-foreground/80 text-sm font-medium">Working directory cleared</span>
+               {:else}
+                       <Folder class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
+                       <span class="text-foreground/80 text-sm font-medium">Set working directory to&nbsp;</span>
+                       <span class="font-mono text-foreground/90 text-sm break-all" title={info.path}>
+                               {info.display}
+                       </span>
+               {/if}
+       </div>
+{/if}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSynthetic.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSynthetic.svelte
new file mode 100644 (file)
index 0000000..1597df2
--- /dev/null
@@ -0,0 +1,23 @@
+<script lang="ts">
+       import { parseCwdMessage } from '$lib/utils';
+       import type { DatabaseMessage } from '$lib/types';
+       import ChatMessageCwdChange from './ChatMessageCwdChange.svelte';
+
+       interface Props {
+               class?: string;
+               message: DatabaseMessage;
+       }
+
+       let { class: className = '', message }: Props = $props();
+
+       // Synthetic messages render a dedicated UI, never a user bubble. The only
+       // kind today is the working-directory change; parse the content so the
+       // row reuses the exact synthetic text (and future kinds slot in here).
+       let isCwdChange = $derived(parseCwdMessage(message.content) !== null);
+</script>
+
+{#if isCwdChange}
+       <ChatMessageCwdChange {message} class={className} />
+{:else}
+       <span class="text-muted-foreground block text-sm {className}">{message.content}</span>
+{/if}
index b1daedfc818c338bf1cc5e1d864746f66e364f54..1d6cccc9f3ca67ccf73def798c80737eaee8a2d6 100644 (file)
@@ -12,6 +12,7 @@
        import ChatMessageToolCallBlockExecShellCommand from './ChatMessageToolCallBlockExecShellCommand.svelte';
        import ChatMessageToolCallBlockFileGlobSearch from './ChatMessageToolCallBlockFileGlobSearch.svelte';
        import ChatMessageToolCallBlockGetDatetime from './ChatMessageToolCallBlockGetDatetime.svelte';
+       import ChatMessageToolCallBlockGetInfo from './ChatMessageToolCallBlockGetInfo.svelte';
        import ChatMessageToolCallBlockGrepSearch from './ChatMessageToolCallBlockGrepSearch.svelte';
        import ChatMessageToolCallBlockReadFile from './ChatMessageToolCallBlockReadFile.svelte';
        import ChatMessageToolCallBlockRunJavascript from './ChatMessageToolCallBlockRunJavascript.svelte';
@@ -40,6 +41,8 @@
        <ChatMessageToolCallBlockSearchResults {section} {open} {isStreaming} {onToggle} />
 {:else if section.toolName === BuiltInTool.GET_DATETIME}
        <ChatMessageToolCallBlockGetDatetime {section} {isStreaming} />
+{:else if section.toolName === BuiltInTool.GET_INFO}
+       <ChatMessageToolCallBlockGetInfo {section} {isStreaming} />
 {:else if section.toolName === BuiltInTool.READ_FILE}
        <ChatMessageToolCallBlockReadFile {section} {open} {isStreaming} {onToggle} />
 {:else if section.toolName === BuiltInTool.EDIT_FILE}
index b990c3898b236fd18f400860de753542c2b73c0d..f8618864c95374416eb1f1956ef9944a6ad76001 100644 (file)
@@ -1,7 +1,8 @@
 <script lang="ts">
        import { XCircle } from '@lucide/svelte';
        import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
-       import { computeLineDiff, prefixFor, type AgenticSection } from '$lib/utils';
+       import { computeLineDiff, prefixFor, abbreviateHome, type AgenticSection } from '$lib/utils';
+       import { toolsStore } from '$lib/stores/tools.svelte';
        import { parseEditFileMeta } from './parsers/edit-file';
        import ToolCallBlock from './ToolCallBlock.svelte';
 
@@ -15,6 +16,7 @@
        let { section, open, isStreaming, onToggle }: Props = $props();
 
        const editFileMeta = $derived(parseEditFileMeta(section));
+       const home = $derived(toolsStore.serverHome);
        const editDiffs = $derived(
                (editFileMeta?.edits ?? []).map((edit) => computeLineDiff(edit.oldText, edit.newText))
        );
@@ -23,7 +25,9 @@
 <ToolCallBlock {section} {open} {isStreaming} meta={editFileMeta} {onToggle}>
        {#snippet titleSnippet()}
                <span class="text-muted-foreground">Edit file </span>
-               <span class="font-mono">{editFileMeta?.filePath}</span>
+               <span class="font-mono" title={editFileMeta?.filePath}
+                       >{abbreviateHome(editFileMeta?.filePath ?? '', home)}</span
+               >
                {#if editFileMeta?.errorMessage}
                        <span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
                {/if}
index 5de801d39a488d987e7a9189667494dbd9db0541..8128ee30dd18a800fca86512ebf4614b297f5811 100644 (file)
@@ -12,6 +12,7 @@
        import { config } from '$lib/stores/settings.svelte';
        import { TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
        import {
+               abbreviateHome,
                highlightCode,
                isExitCodeSummaryLine,
                parseExecShellCommandError,
@@ -21,6 +22,7 @@
                type ExecShellExitStatus,
                type ToolResultLine
        } from '$lib/utils';
+       import { toolsStore } from '$lib/stores/tools.svelte';
        import { parseExecShellCommandMeta } from './parsers/exec-shell-command';
        import type { DatabaseMessageExtra } from '$lib/types';
        import ToolCallBlock from './ToolCallBlock.svelte';
                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;
        }
index ad082039ff60e49e79d5d6e0808e3e26f7e845aa..7b674e8184d5e4788cd7497c0c37ef97e8f520f5 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
        import { XCircle } from '@lucide/svelte';
-       import { type AgenticSection } from '$lib/utils';
+       import { abbreviateHome, type AgenticSection } from '$lib/utils';
+       import { toolsStore } from '$lib/stores/tools.svelte';
        import { parseFileGlobSearchMeta } from './parsers/file-glob-search';
        import ToolCallBlock from './ToolCallBlock.svelte';
 
@@ -14,6 +15,7 @@
        let { section, open, isStreaming, onToggle }: Props = $props();
 
        const fileGlobMeta = $derived(parseFileGlobSearchMeta(section));
+       const home = $derived(toolsStore.serverHome);
 </script>
 
 <ToolCallBlock {section} {open} {isStreaming} meta={fileGlobMeta} {onToggle}>
@@ -26,7 +28,9 @@
                                <span class="font-mono">{fileGlobMeta.include}</span>
                        {/if}
                        <span class="text-muted-foreground">&nbsp;in&nbsp;</span>
-                       <span class="font-mono">{fileGlobMeta.path}</span>
+                       <span class="font-mono" title={fileGlobMeta.path}
+                               >{abbreviateHome(fileGlobMeta.path, home)}</span
+                       >
                {/if}
        {/snippet}
 
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetInfo.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetInfo.svelte
new file mode 100644 (file)
index 0000000..d45e361
--- /dev/null
@@ -0,0 +1,69 @@
+<script lang="ts">
+       import { Info, Loader2 } from '@lucide/svelte';
+       import { AgenticSectionType } from '$lib/enums';
+       import { abbreviateHome, type AgenticSection } from '$lib/utils';
+       import { toolsStore } from '$lib/stores/tools.svelte';
+
+       interface Props {
+               section: AgenticSection;
+               isStreaming?: boolean;
+       }
+
+       let { section, isStreaming = false }: Props = $props();
+
+       const isPending = $derived(section.type === AgenticSectionType.TOOL_CALL_PENDING);
+       const isStreamingCall = $derived(section.type === AgenticSectionType.TOOL_CALL_STREAMING);
+       const showSpinner = $derived(isPending || (isStreamingCall && isStreaming));
+
+       type GetInfoMeta = {
+               os?: string;
+               cwd?: string;
+               errorMessage?: string;
+       };
+
+       function parseGetInfoMeta(toolResultString: string | undefined): GetInfoMeta {
+               if (!toolResultString) return {};
+
+               try {
+                       const parsed: unknown = JSON.parse(toolResultString);
+                       if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
+                               const obj = parsed as Record<string, unknown>;
+                               if (typeof obj.error === 'string') return { errorMessage: obj.error };
+                               return {
+                                       os: typeof obj.os === 'string' ? obj.os : undefined,
+                                       cwd: typeof obj.cwd === 'string' ? obj.cwd : undefined
+                               };
+                       }
+               } catch {
+                       // not JSON - nothing to show
+               }
+
+               return {};
+       }
+
+       const infoMeta = $derived(parseGetInfoMeta(section.toolResult));
+       const home = $derived(toolsStore.serverHome);
+       const cwdDisplay = $derived(abbreviateHome(infoMeta.cwd ?? '', home));
+</script>
+
+<div class="text-muted-foreground flex items-center gap-2 py-1.5">
+       <Info class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
+       {#if showSpinner}
+               <span class="text-foreground/80 text-sm font-medium">Runtime info</span>
+               <Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
+       {:else if infoMeta.errorMessage}
+               <span class="text-foreground/80 text-sm font-medium">Runtime info&nbsp;</span>
+               <span class="text-red-600 text-xs italic dark:text-red-400">-&nbsp;{infoMeta.errorMessage}</span
+               >
+       {:else if infoMeta.os || infoMeta.cwd}
+               <span class="text-foreground/80 text-sm font-medium">Runtime info&nbsp;</span>
+               {#if infoMeta.os}
+                       <span class="font-mono text-foreground/90 text-sm">{infoMeta.os}</span>
+               {/if}
+               {#if infoMeta.cwd}
+                       <span class="font-mono text-foreground/90 text-sm" title={infoMeta.cwd}>{cwdDisplay}</span>
+               {/if}
+       {:else}
+               <span class="text-foreground/80 text-sm font-medium">Runtime info</span>
+       {/if}
+</div>
index afb06fef71685fcda4f3c332767c6e7bcdd4ec10..ac805a2e4d37e859002df122cb16bd010ca55713 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
        import { XCircle } from '@lucide/svelte';
-       import { type AgenticSection } from '$lib/utils';
+       import { abbreviateHome, type AgenticSection } from '$lib/utils';
+       import { toolsStore } from '$lib/stores/tools.svelte';
        import { parseGrepSearchMeta } from './parsers/grep-search';
        import ToolCallBlock from './ToolCallBlock.svelte';
 
@@ -14,6 +15,7 @@
        let { section, open, isStreaming, onToggle }: Props = $props();
 
        const grepMeta = $derived(parseGrepSearchMeta(section));
+       const home = $derived(toolsStore.serverHome);
 </script>
 
 <ToolCallBlock {section} {open} {isStreaming} meta={grepMeta} {onToggle}>
@@ -22,7 +24,7 @@
                        <span class="text-muted-foreground">Search for&nbsp;</span>
                        <span class="font-mono">{grepMeta.pattern}</span>
                        <span class="text-muted-foreground">&nbsp;in&nbsp;</span>
-                       <span class="font-mono">{grepMeta.path}</span>
+                       <span class="font-mono" title={grepMeta.path}>{abbreviateHome(grepMeta.path, home)}</span>
                {/if}
        {/snippet}
 
index eda067662305c83ad0bfae1858bc3520eda9d59d..74b9452093ef532e9a62e79d834daad6d5bcc414 100644 (file)
@@ -2,7 +2,8 @@
        import { XCircle } from '@lucide/svelte';
        import { SyntaxHighlightedCode } from '$lib/components/app';
        import { MAX_HEIGHT_CODE_BLOCK, RESULT_STAT_SEPARATOR } from '$lib/constants';
-       import { type AgenticSection } from '$lib/utils';
+       import { abbreviateHome, type AgenticSection } from '$lib/utils';
+       import { toolsStore } from '$lib/stores/tools.svelte';
        import { parseWriteFileMeta } from './parsers/write-file';
        import ToolCallBlock from './ToolCallBlock.svelte';
 
        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}
index cd06ec0366197e8786b33a603aa566c66696a34e..57108f306521f257e7313fd4b8ecb6c2c0f74b6c 100644 (file)
@@ -272,6 +272,16 @@ export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResou
  */
 export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
 
+/**
+ * Working directory selector for agent mode. Renders a chip below the chat
+ * form; clicking it opens a popover with a directory picker backed by the
+ * server's `file_glob_search` built-in tool (POST /tools). The picked
+ * directory is exposed via `bind:directory`; changing it records a
+ * synthetic "Set working directory to ..." user message into chat history
+ * and is enforced on tool calls via the `x-tool-cwd` request header.
+ */
+export { default as ChatFormWorkingDirectory } from './ChatForm/ChatFormWorkingDirectory.svelte';
+
 /**
  * **ChatFormPickerMcpPrompts** - MCP prompt selection interface
  *
@@ -557,6 +567,22 @@ export { default as ChatMessageStatisticsBadge } from './ChatMessages/ChatMessag
  */
 export { default as ChatMessageMcpPrompt } from './ChatMessages/ChatMessage/ChatMessageMcpPrompt/ChatMessageMcpPrompt.svelte';
 
+/**
+ * Synthetic working-directory-change message. Rendered in place of a user
+ * bubble when the message content parses as a cwd message (see
+ * parseCwdMessage); shows the new cwd with the same folder-row treatment
+ * the tool-call UI used.
+ */
+export { default as ChatMessageCwdChange } from './ChatMessages/ChatMessage/ChatMessageCwdChange.svelte';
+
+/**
+ * Generic wrapper for UI-generated (synthetic) messages. Routes the
+ * working-directory change to ChatMessageCwdChange and renders a muted
+ * fallback for any other synthetic text, so no synthetic message ever
+ * surfaces as a user bubble.
+ */
+export { default as ChatMessageSynthetic } from './ChatMessages/ChatMessage/ChatMessageSynthetic.svelte';
+
 /**
  * Formatted content display for MCP prompt messages. Renders the full prompt
  * content with arguments in a readable format. Used within ChatMessageMcpPrompt
index 0721e6484d6280a0459d2a15fa46b6db9a8c72ee..a6c4981d0480f405bff2f13e1d55ae9d38fb4025 100644 (file)
@@ -15,6 +15,7 @@ import {
        FilePlus,
        FileSearch,
        FileText,
+       Info,
        SearchCode,
        Terminal
 } from '@lucide/svelte';
@@ -41,6 +42,7 @@ export const BUILTIN_TOOL_UI: Readonly<Record<BuiltInTool, BuiltinToolUiEntry>>
                source: ToolSource.BUILTIN
        },
        [BuiltInTool.GET_DATETIME]: { icon: Clock, label: 'Current time', source: ToolSource.BUILTIN },
+       [BuiltInTool.GET_INFO]: { icon: Info, label: 'Runtime info', source: ToolSource.BUILTIN },
        [BuiltInTool.EXEC_SHELL_COMMAND]: {
                icon: Terminal,
                label: 'Run command',
index 100432c18c0d51879e38424d4ecd8a1cc5889a41..1f5ec7f0bab555ae5b581fc98b130597c42c5428 100644 (file)
@@ -40,6 +40,7 @@ export * from './mcp';
 export * from './mcp-form';
 export * from './mcp-resource';
 export * from './message-export';
+export * from './path-display';
 export * from './model-id';
 export * from './model-loading';
 export * from './sse';
@@ -60,3 +61,4 @@ export * from './ui';
 export * from './uri-template';
 export * from './url';
 export * from './viewport';
+export * from './working-directory';
diff --git a/tools/ui/src/lib/constants/path-display.ts b/tools/ui/src/lib/constants/path-display.ts
new file mode 100644 (file)
index 0000000..95877d7
--- /dev/null
@@ -0,0 +1,22 @@
+/**
+ * Constants for synthetic working-directory messages.
+ *
+ * The synthetic cwd-change message is text the UI renders as a folder row
+ * and the model sees as a turn reminder. The prefix and cleared marker keep
+ * the human-readable wording; the file-link regexes parse the
+ * `[file:///abs/path](display)` payload back out on the UI side.
+ */
+
+import { UrlProtocol } from '$lib/enums';
+
+export const CWD_CHANGED_PREFIX = 'Set working directory to ';
+export const CWD_CLEARED_TEXT = 'Working directory cleared';
+
+export const HOME_TILDE = '~';
+export const HOME_TILDE_PREFIX = '~/'; // tilde plus path separator
+
+/** Scheme prefix of the file link embedded in a synthetic cwd message. */
+export const FILE_URI_PREFIX = `${UrlProtocol.FILE}//`;
+
+/** Matches the leading `[file:///abs/path](display)` link; not anchored to the end so trailing guidance may follow. */
+export const CWD_LINK_REGEX = /^\[file:\/\/([\s\S]*?)\]\(([\s\S]*?)\)/;
index 4d9385f9f5ca12d0bf8726dc13f4915e552ae599..65f4457c969dd74164cdfd387d820f8167386bb6 100644 (file)
@@ -1,5 +1,8 @@
 import { ToolSource } from '$lib/enums/tools.enums';
 
+/** HTTP header carrying the working directory a tool call runs in. The server resolves relative paths against it; the model cannot override it. */
+export const X_TOOL_CWD_HEADER = 'x-tool-cwd';
+
 export const TOOL_GROUP_LABELS = {
        [ToolSource.BUILTIN]: 'Built-in',
        [ToolSource.CUSTOM]: 'JSON Schema',
diff --git a/tools/ui/src/lib/constants/working-directory.ts b/tools/ui/src/lib/constants/working-directory.ts
new file mode 100644 (file)
index 0000000..8f4f4fa
--- /dev/null
@@ -0,0 +1,40 @@
+/**
+ * Constants for the working-directory picker's glob search.
+ *
+ * The picker glob-matches home-relative names client-side. Character classes
+ * are built case-insensitively and the reserved glob metacharacters are
+ * escaped (passed through literally) so a query never changes matching.
+ */
+
+export const GLOB_WILDCARD = '*';
+
+/** Character that starts and ends a glob character-class fragment. */
+export const GLOB_RANGE_OPEN = '[';
+export const GLOB_RANGE_CLOSE = ']';
+
+/** Query characters that carry glob meaning and are passed through literally. */
+export const GLOB_SPECIAL_CHARS = '*?[]';
+
+/** Separator Windows accepts alongside `/`, and a legal POSIX filename character. */
+export const WINDOWS_SEPARATOR = '\\';
+
+/** `C:`, the drive part of a Windows absolute path. */
+export const DRIVE_PREFIX_REGEX = /^[A-Za-z]:/;
+
+/** `C:` or `C:/`, the root of a Windows drive-absolute path. */
+export const DRIVE_ROOT_REGEX = /^[A-Za-z]:\/?/;
+
+/** `//host/share` or `//host/share/`, the root of a UNC path. */
+export const UNC_ROOT_REGEX = /^\/\/[^/]+\/[^/]+\/?/;
+
+// Search tuning for the picker's file_glob_search calls.
+export const SEARCH_DEBOUNCE_MS = 180;
+export const SEARCH_LIMIT = 100;
+export const MAX_RESULTS_SHOWN = 20;
+// Home-relative globs descend deeper than path navigation, which only
+// needs the direct children of the parent.
+export const SEARCH_MAX_DEPTH = 6;
+export const PATH_NAV_MAX_DEPTH = 1;
+// Native folder-picker resolution searches a shallow, bounded window.
+export const NATIVE_MAX_DEPTH = 4;
+export const NATIVE_LIMIT = 20;
index ee14293fc952db23d8b4143ba3feb5409b51da70..4da9e6ac8af073c1787bea0b45266fd585f85eff 100644 (file)
@@ -72,6 +72,12 @@ export { ColorMode, HtmlInputType, McpPromptVariant, TooltipSide, UrlProtocol }
 
 export { KeyboardKey } from './keyboard.enums';
 
-export { BuiltInTool, ToolSource, ToolPermissionDecision, ToolResponseField } from './tools.enums';
+export {
+       BuiltInTool,
+       GlobSearchType,
+       ToolSource,
+       ToolPermissionDecision,
+       ToolResponseField
+} from './tools.enums';
 
 export { SplashOrientation } from './splash.enums';
index 9985e1f4aa986b6d1b5d931b368240c4408d7f0a..7a9751ee79937865a16540256b2d656f38c61c5e 100644 (file)
@@ -17,6 +17,16 @@ export enum ToolResponseField {
        ERROR = 'error'
 }
 
+/**
+ * Entry types accepted by the `file_glob_search` tool's `type` parameter.
+ * Mirrors the server-side validation in server-tools.cpp.
+ */
+export enum GlobSearchType {
+       FILE = 'file',
+       DIR = 'dir',
+       ALL = 'all'
+}
+
 /**
  * Wire-format identifiers for built-in and frontend tools. The string
  * value matches what the model emits in tool call names, so comparing
@@ -30,6 +40,7 @@ export enum BuiltInTool {
        EDIT_FILE = 'edit_file',
        WRITE_FILE = 'write_file',
        GET_DATETIME = 'get_datetime',
+       GET_INFO = 'get_info',
        FILE_GLOB_SEARCH = 'file_glob_search',
        GREP_SEARCH = 'grep_search',
        EXEC_SHELL_COMMAND = 'exec_shell_command',
index 82996379428e429edba0bc777eea0ffcd7c8f5ac..6ba0222e9737145a67b096c0208c4b4a24ec0955 100644 (file)
@@ -24,6 +24,7 @@ export enum McpPromptVariant {
  */
 export enum UrlProtocol {
        DATA = 'data:',
+       FILE = 'file:',
        HTTP = 'http:',
        HTTPS = 'https:',
        WEBSOCKET = 'ws:',
index 0a7c59b9c8c5d221f3753c63f8c4f2dd6686f7ba..43259b73bec7b34f0d3eb04b61e07f08dc533204 100644 (file)
@@ -674,7 +674,8 @@ export class DatabaseService {
                                                                serverId: o.serverId,
                                                                enabled: o.enabled
                                                        }))
-                                               : undefined
+                                               : undefined,
+                                       cwd: sourceConv.cwd
                                };
 
                                await db[IDXDB_TABLES.conversations].add(newConv);
index 05cc42fd5f7c583772d6152be674be52a19794c1..765fb532212040e2a7f34c69d3a112bbcd30d9e5 100644 (file)
@@ -2,7 +2,7 @@ import { base } from '$app/paths';
 import { getJsonHeaders } from '$lib/utils/api-headers';
 import { parseSseJsonStream, type SseJsonEvent } from '$lib/utils/sse';
 import { apiFetch } from '$lib/utils';
-import { API_TOOLS } from '$lib/constants';
+import { API_TOOLS, X_TOOL_CWD_HEADER } from '$lib/constants';
 import { ToolResponseField } from '$lib/enums';
 import type { ToolExecutionResult, ServerBuiltinToolInfo } from '$lib/types';
 
@@ -18,15 +18,21 @@ export class ToolsService {
 
        /**
         * Execute a built-in tool on the server.
+        *
+        * @param cwd - Working directory for the tool call, sent as the
+        * x-tool-cwd request header. The server resolves relative paths
+        * against it; the model cannot override it.
         */
        static async executeTool(
                toolName: string,
                params: Record<string, unknown>,
-               signal?: AbortSignal
+               signal?: AbortSignal,
+               cwd?: string
        ): Promise<ToolExecutionResult> {
                const result = await apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
                        method: 'POST',
                        body: JSON.stringify({ tool: toolName, params }),
+                       headers: cwd ? { [X_TOOL_CWD_HEADER]: cwd } : undefined,
                        signal
                });
 
@@ -41,6 +47,25 @@ export class ToolsService {
                return { content: JSON.stringify(result), isError: false };
        }
 
+       /**
+        * Execute a built-in tool and return the raw JSON response. Unlike
+        * executeTool, this preserves structured fields (e.g. file_glob_search's
+        * `entries` and `base`) that the flattened ToolExecutionResult drops.
+        */
+       static async executeToolRaw(
+               toolName: string,
+               params: Record<string, unknown>,
+               signal?: AbortSignal,
+               cwd?: string
+       ): Promise<Record<string, unknown>> {
+               return apiFetch<Record<string, unknown>>(API_TOOLS.EXECUTE, {
+                       method: 'POST',
+                       body: JSON.stringify({ tool: toolName, params }),
+                       headers: cwd ? { [X_TOOL_CWD_HEADER]: cwd } : undefined,
+                       signal
+               });
+       }
+
        /**
         * Stream a built-in tool's output chunks from the server. The server
         * `POST /tools` endpoint with `{stream: true}` emits `data: {"chunk": "..."}`
@@ -59,9 +84,11 @@ export class ToolsService {
        static async *streamTool(
                toolName: string,
                params: Record<string, unknown>,
-               signal?: AbortSignal
+               signal?: AbortSignal,
+               cwd?: string
        ): AsyncGenerator<ToolStreamEvent> {
                const headers = getJsonHeaders();
+               if (cwd) headers[X_TOOL_CWD_HEADER] = cwd;
                const response = await fetch(`${base}${API_TOOLS.EXECUTE}`, {
                        method: 'POST',
                        headers,
index 3eef745ef424739fff6d504fcd31e171157078e1..71c3347be6a40cbe7bcefb4a0ab1727b2c6331db 100644 (file)
@@ -22,6 +22,7 @@
 
 import { ChatService } from '$lib/services';
 import { config } from '$lib/stores/settings.svelte';
+import { conversationsStore } from '$lib/stores/conversations.svelte';
 import { mcpStore } from '$lib/stores/mcp.svelte';
 import { modelsStore } from '$lib/stores/models.svelte';
 import { toolsStore } from '$lib/stores/tools.svelte';
@@ -812,11 +813,12 @@ class AgenticStore {
                                                        updateToolResultMessage
                                                ) {
                                                        const args = this.parseToolArguments(toolCall.function.arguments);
-                                                       const msg = await createToolResultMessage(toolCall.id, '');
+                                                       const cwd = conversationsStore.activeConversation?.cwd;
+                                                       const msg = await createToolResultMessage(toolCall.id, '', undefined, cwd);
                                                        createdToolResultMessageId = msg.id;
 
                                                        let accumulated = '';
-                                                       for await (const ev of ToolsService.streamTool(toolName, args, signal)) {
+                                                       for await (const ev of ToolsService.streamTool(toolName, args, signal, cwd)) {
                                                                if (ev.chunk !== null) {
                                                                        accumulated += ev.chunk;
                                                                        await updateToolResultMessage(msg.id, accumulated);
@@ -835,7 +837,8 @@ class AgenticStore {
                                                        result = accumulated;
                                                } else if (toolSource === ToolSource.BUILTIN) {
                                                        const args = this.parseToolArguments(toolCall.function.arguments);
-                                                       const executionResult = await ToolsService.executeTool(toolName, args, signal);
+                                                       const cwd = conversationsStore.activeConversation?.cwd;
+                                                       const executionResult = await ToolsService.executeTool(toolName, args, signal, cwd);
 
                                                        result = executionResult.content;
 
index 935ed8e165da695645c045dde281b36328f49f7f..75c4dd84885d573b3fdf2f550a947ee412eae9ed 100644 (file)
@@ -35,9 +35,12 @@ import {
        findDescendantMessages,
        findLeafNode,
        findMessageById,
+       formatCwdMessage,
        isAbortError,
-       generateConversationTitle
+       generateConversationTitle,
+       CWD_CLEARED_TEXT
 } from '$lib/utils';
+import { toolsStore } from '$lib/stores/tools.svelte';
 import { classifyContinueIntent } from '$lib/utils/agentic';
 import {
        MAX_INACTIVE_CONVERSATION_STATES,
@@ -870,7 +873,8 @@ class ChatStore {
                content: string,
                type: MessageType = MessageType.TEXT,
                parent: string = '-1',
-               extras?: DatabaseMessageExtra[]
+               extras?: DatabaseMessageExtra[],
+               isSynthetic?: boolean
        ): Promise<DatabaseMessage> {
                const activeConv = conversationsStore.activeConversation;
                if (!activeConv) throw new Error('No active conversation');
@@ -893,7 +897,8 @@ class ChatStore {
                                timestamp: Date.now(),
                                toolCalls: '',
                                children: [],
-                               extra: extras
+                               extra: extras,
+                               isSynthetic
                        },
                        parentId
                );
@@ -903,6 +908,33 @@ class ChatStore {
                return message;
        }
 
+       /**
+        * Record a working-directory change into chat history as a synthetic
+        * user message, so the model sees it on its next turn (the client
+        * sends the cwd itself via the x-tool-cwd header on tool calls).
+        * A plain user message is used because some chat templates reject
+        * tool messages without a preceding tool call.
+        */
+       async recordCwdChange(cwd: string | null): Promise<void> {
+               const content = cwd
+                       ? formatCwdMessage(cwd, await toolsStore.resolveServerHome())
+                       : CWD_CLEARED_TEXT;
+
+               // Reuse the trailing cwd row when it is already the last message, so
+               // repeated picks update it in place instead of stacking another row.
+               const last = conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1];
+               if (last && last.role === MessageRole.USER && last.isSynthetic === true) {
+                       await DatabaseService.updateMessage(last.id, { content, isSynthetic: true });
+                       conversationsStore.updateMessageAtIndex(conversationsStore.activeMessages.length - 1, {
+                               content,
+                               isSynthetic: true
+                       });
+                       return;
+               }
+
+               await this.addMessage(MessageRole.USER, content, MessageType.TEXT, '-1', undefined, true);
+       }
+
        async addSystemPrompt(): Promise<void> {
                let activeConv = conversationsStore.activeConversation;
                if (!activeConv) {
@@ -1055,6 +1087,7 @@ class ChatStore {
                                const rootId = await DatabaseService.createRootMessage(currentConv.id);
                                const currentConfig = config();
                                const systemPrompt = currentConfig.systemMessage?.toString().trim();
+                               let sysOrRootId = rootId;
                                if (systemPrompt) {
                                        const systemMessage = await DatabaseService.createSystemMessage(
                                                currentConv.id,
@@ -1062,8 +1095,25 @@ class ChatStore {
                                                rootId
                                        );
                                        conversationsStore.addMessageToActive(systemMessage);
-                                       parentIdForUserMessage = systemMessage.id;
-                               } else parentIdForUserMessage = rootId;
+                                       sysOrRootId = systemMessage.id;
+                               }
+                               // Reflect a working directory picked on the new-chat screen into
+                               // chat history before the first user message, so the model sees
+                               // it on its first turn. createConversation() has already threaded
+                               // the pending pick onto the conversation.
+                               if (currentConv.cwd) {
+                                       const cwdMessage = await this.addMessage(
+                                               MessageRole.USER,
+                                               formatCwdMessage(currentConv.cwd, await toolsStore.resolveServerHome()),
+                                               MessageType.TEXT,
+                                               sysOrRootId,
+                                               undefined,
+                                               true
+                                       );
+                                       parentIdForUserMessage = cwdMessage.id;
+                               } else {
+                                       parentIdForUserMessage = sysOrRootId;
+                               }
                        }
                        const userMessage = await this.addMessage(
                                MessageRole.USER,
@@ -1282,7 +1332,8 @@ class ChatStore {
                        createToolResultMessage: async (
                                toolCallId: string,
                                content: string,
-                               extras?: DatabaseMessageExtra[]
+                               extras?: DatabaseMessageExtra[],
+                               toolCwd?: string
                        ) => {
                                const msg = await DatabaseService.createMessageBranch(
                                        {
@@ -1291,6 +1342,7 @@ class ChatStore {
                                                role: MessageRole.TOOL,
                                                content,
                                                toolCallId,
+                                               toolCwd,
                                                timestamp: Date.now(),
                                                toolCalls: '',
                                                children: [],
index 1a1157c72c2225b047f99bc48b8f9c5eefd9257a..c8b292039811598293686096553c817cff44a8e6 100644 (file)
@@ -86,6 +86,15 @@ class ConversationsStore {
        /** Global (non-conversation-specific) reasoning effort default */
        pendingReasoningEffort = $state<ReasoningEffort>(ConversationsStore.loadReasoningEffortDefault());
 
+       /**
+        * Working directory picked on the empty new-chat screen, before any
+        * conversation exists. Consumed by `chatStore.sendMessage()`, which
+        * records it into chat history as a synthetic message on first send.
+        * Cleared by `loadConversation` and `clearActiveConversation` so a
+        * stale pick can't bleed onto an unrelated chat.
+        */
+       pendingCwd = $state<string | null>(null);
+
        /** Load reasoning effort default from localStorage, DEFAULT defers to the server */
        private static loadReasoningEffortDefault(): ReasoningEffort {
                if (typeof globalThis.localStorage === 'undefined') return ReasoningEffort.DEFAULT;
@@ -250,9 +259,13 @@ class ConversationsStore {
                // No MCP override list is seeded: getAllMcpServerOverrides resolves
                // servers without a per-conversation override to `mcpServers[i].enabled`,
                // and only explicit toggles are stored on the conversation.
+               // Working directory picked on the new-chat screen gets threaded in
+               // here too, then cleared so it doesn't bleed onto subsequent new chats.
                const conversation = await DatabaseService.createConversation(conversationName, {
-                       reasoningEffort: this.pendingReasoningEffort
+                       reasoningEffort: this.pendingReasoningEffort,
+                       cwd: this.pendingCwd ?? undefined
                });
+               this.pendingCwd = null;
 
                this.conversations = [conversation, ...this.conversations];
                this.activeConversation = conversation;
@@ -276,6 +289,10 @@ class ConversationsStore {
                                return false;
                        }
 
+                       // Drop any cwd the user drafted on the empty new-chat screen -
+                       // it doesn't belong to this conversation.
+                       this.pendingCwd = null;
+
                        this.activeConversation = conversation;
 
                        if (conversation.currNode) {
@@ -306,6 +323,7 @@ class ConversationsStore {
                this.activeMessages = [];
                // reload defaults so new chats inherit persisted state
                this.pendingReasoningEffort = ConversationsStore.loadReasoningEffortDefault();
+               this.pendingCwd = null;
        }
 
        /**
@@ -855,6 +873,42 @@ class ConversationsStore {
                }
        }
 
+       /**
+        * Sets the working directory for the active conversation. Pass `null` or
+        * an empty string to clear it, which restores the picker's empty state.
+        *
+        * On the empty new-chat screen (no active conversation yet), the value
+        * is buffered into `pendingCwd` so the user can pick before
+        * sending the first message; `createConversation()` consumes it.
+        *
+        * @param value - Absolute server-side path to the working directory, or null to clear
+        */
+       async setCwd(value: string | null): Promise<void> {
+               const trimmed = value?.trim() || undefined;
+
+               // No chat yet - buffer for the first chat the user creates.
+               if (!this.activeConversation) {
+                       this.pendingCwd = trimmed ?? null;
+                       return;
+               }
+
+               this.activeConversation = {
+                       ...this.activeConversation,
+                       cwd: trimmed
+               };
+
+               await DatabaseService.updateConversation(this.activeConversation.id, {
+                       cwd: trimmed
+               });
+
+               const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
+               if (convIndex !== -1) {
+                       this.conversations[convIndex].cwd = trimmed;
+                       this.conversations = [...this.conversations];
+               }
+               this.pendingCwd = null;
+       }
+
        /**
         * Forks a conversation at a specific message, creating a new conversation
         * containing messages from root up to the target message, then navigates to it.
@@ -1169,6 +1223,7 @@ if (browser) {
 export const conversations = () => conversationsStore.conversations;
 export const activeConversation = () => conversationsStore.activeConversation;
 export const activeMessages = () => conversationsStore.activeMessages;
+export const pendingCwd = () => conversationsStore.pendingCwd;
 export const isConversationsInitialized = () => conversationsStore.isInitialized;
 
 /**
index 66e61ab4db7134b6305405ae4a1cca0fa5fb8087..5136101e751c3b54057237b870fc3132ae740ed9 100644 (file)
@@ -1,11 +1,19 @@
 import type { OpenAIToolDefinition, ToolEntry, ToolGroup } from '$lib/types';
 import { ToolsService } from '$lib/services/tools.service';
 import { mcpStore } from '$lib/stores/mcp.svelte';
-import { HealthCheckStatus, JsonSchemaType, ToolCallType, ToolSource } from '$lib/enums';
+import {
+       BuiltInTool,
+       GlobSearchType,
+       HealthCheckStatus,
+       JsonSchemaType,
+       ToolCallType,
+       ToolSource
+} from '$lib/enums';
 import { config } from '$lib/stores/settings.svelte';
 import {
        DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
        buildSandboxToolDefinition,
+       HOME_TILDE,
        TOOL_GROUP_LABELS,
        TOOL_SERVER_LABELS
 } from '$lib/constants';
@@ -20,6 +28,7 @@ class ToolsStore {
        private _error = $state<string | null>(null);
        private _disabledTools = $state(new SvelteSet<string>());
        private _toolsEndpointUnreachable = $state(false);
+       private _serverHome = $state<string | null | undefined>(undefined);
 
        constructor() {
                try {
@@ -138,6 +147,10 @@ class ToolsStore {
                return this._builtinTools;
        }
 
+       get serverHome(): string | null {
+               return this._serverHome ?? null;
+       }
+
        get mcpTools(): OpenAIToolDefinition[] {
                return this.mcpEntries().map((e) => e.definition);
        }
@@ -488,6 +501,29 @@ class ToolsStore {
                        this._loading = false;
                }
        }
+
+       /**
+        * Absolute home directory on the server, resolved once per session via
+        * file_glob_search's `base` field (the server expands `~`). Anchors the
+        * directory picker's search scope and the `~` abbreviation of cwd
+        * displays. Returns null when tools are unavailable.
+        */
+       async resolveServerHome(): Promise<string | null> {
+               if (this._serverHome !== undefined) return this._serverHome;
+               try {
+                       const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
+                               path: HOME_TILDE,
+                               type: GlobSearchType.DIR,
+                               max_depth: 1,
+                               limit: 1
+                       });
+                       this._serverHome = typeof res.base === 'string' ? res.base : null;
+               } catch {
+                       // searches still work via a literal `~`, only `~` abbreviation degrades
+                       this._serverHome = null;
+               }
+               return this._serverHome;
+       }
 }
 
 export const toolsStore = new ToolsStore();
index e235db78b76434f43255be5650ca5faea8a6496c..84136d29a8383dd25ae4328d005a06de0303dd77 100644 (file)
@@ -109,7 +109,8 @@ export interface AgenticFlowCallbacks {
        createToolResultMessage?: (
                toolCallId: string,
                content: string,
-               extras?: DatabaseMessageExtra[]
+               extras?: DatabaseMessageExtra[],
+               toolCwd?: string
        ) => Promise<DatabaseMessage>;
        /** Update an already-created tool result message. Used while a streaming
         *  tool (e.g. exec_shell_command) accumulates output chunks before its
index 0d6063519820fb5806b1c282a9e7e89e37106c40..1b2c18b2c48845f23a8c692aeed59a0301fe14d3 100644 (file)
@@ -108,7 +108,8 @@ export interface ChatStreamCallbacks {
        createToolResultMessage?: (
                toolCallId: string,
                content: string,
-               extras?: DatabaseMessageExtra[]
+               extras?: DatabaseMessageExtra[],
+               toolCwd?: string
        ) => Promise<DatabaseMessage>;
        updateToolResultMessage?: (
                messageId: string,
index b01a7b9649c1c0ef39b8402760cf7e798590685b..f5480778dee32956f64442171a5a2e5f6d07916f 100644 (file)
@@ -14,6 +14,7 @@ export interface DatabaseConversation {
        mcpServerOverrides?: McpServerOverride[];
        thinkingEnabled?: boolean;
        reasoningEffort?: ReasoningEffort;
+       cwd?: string;
        forkedFromConversationId?: string;
        pinned?: boolean;
 }
@@ -119,6 +120,10 @@ export interface DatabaseMessage {
        completionId?: string;
        /** Tool call ID for tool result messages (role: 'tool') */
        toolCallId?: string;
+       /** Working directory the tool call ran with (sent via the x-tool-cwd header), stored per call so the UI can show it accurately even after the conversation cwd changes */
+       toolCwd?: string;
+       /** Internal flag marking a UI-generated message (e.g. a cwd change). The row is sent to the model as a "user" turn so chat templates accept it; the flag is only read by the renderer. */
+       isSynthetic?: boolean;
        children: string[];
        extra?: DatabaseMessageExtra[];
        timings?: ChatMessageTimings;
index e6703675127776e67dfe4d8e9fa6cf19147295b9..fecc2b155ad30b7761988ec20312ee34c9b632dd 100644 (file)
@@ -38,6 +38,9 @@ export interface AgenticSection {
        toolArgs?: string;
        toolResult?: string;
        toolResultExtras?: DatabaseMessageExtra[];
+       /** Working directory the tool call ran with (from the tool result
+        *  message), shown by the exec_shell_command renderer. */
+       toolCwd?: string;
        /** ID of the model-side tool call (matches tool_calls[i].id). Lets
         *  downstream consumers correlate a section with the agentic loop's
         *  currently-executing tool, e.g. to drive live-streaming UI state
@@ -116,6 +119,7 @@ function deriveSingleTurnSections(
                        toolArgs: tc.function?.arguments,
                        toolResult: resultMsg?.content,
                        toolResultExtras: resultMsg?.extra,
+                       toolCwd: resultMsg?.toolCwd,
                        toolCallId: tc.id
                });
        }
index f829793f1c7cad4996cacec797e8abcdb62443b5..4c0257639a2e431ded555b642a331230bd17b640 100644 (file)
@@ -158,6 +158,29 @@ export { createBase64DataUrl } from './data-url';
 // Header utilities
 export { parseHeadersToArray, serializeHeaders } from './headers';
 
+// Working-directory display helpers (HOME-style tilde abbreviation)
+export {
+       abbreviateWorkingDir,
+       abbreviateHome,
+       lastPathSegment,
+       formatCwdMessage,
+       parseCwdMessage,
+       CWD_CHANGED_PREFIX,
+       CWD_CLEARED_TEXT,
+       type CwdMessageInfo
+} from './path-display';
+
+// Working-directory picker search helpers
+export {
+       splitPathQuery,
+       buildCaseInsensitiveGlob,
+       rankEntries,
+       joinPath,
+       highlightMatch,
+       type GlobEntry,
+       type PathQuery
+} from './working-directory';
+
 // Agentic content utilities (structured section derivation)
 export {
        deriveAgenticSections,
diff --git a/tools/ui/src/lib/utils/path-display.ts b/tools/ui/src/lib/utils/path-display.ts
new file mode 100644 (file)
index 0000000..4663e6d
--- /dev/null
@@ -0,0 +1,93 @@
+import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
+import { TRAILING_SLASHES_REGEX } from '$lib/constants/url';
+import {
+       CWD_CHANGED_PREFIX,
+       CWD_CLEARED_TEXT,
+       CWD_LINK_REGEX,
+       FILE_URI_PREFIX,
+       HOME_TILDE,
+       HOME_TILDE_PREFIX
+} from '$lib/constants';
+
+/**
+ * Last non-empty slash-delimited segment of `path`, with trailing
+ * slashes stripped. Returns the input unchanged when no `/` is present.
+ */
+export function lastPathSegment(p: string): string {
+       const trimmed = p.replace(TRAILING_SLASHES_REGEX, '');
+       const idx = trimmed.lastIndexOf(PATH_SEPARATOR);
+       return idx === -1 ? trimmed : trimmed.slice(idx + 1);
+}
+
+/**
+ * Abbreviate `path` to `~/...` when it sits under `home`, or to `~` when
+ * it equals `home`. Falls back to `lastPathSegment(path)` when home is
+ * unknown or the path is outside it. `~` semantics are reserved for the
+ * home directory, mirroring how shells render it.
+ */
+export function abbreviateWorkingDir(
+       path: string | null | undefined,
+       home: string | null | undefined
+): string {
+       if (!path) return '';
+       if (!home) return lastPathSegment(path);
+       if (path === home) return HOME_TILDE;
+       if (path.startsWith(home + PATH_SEPARATOR))
+               return HOME_TILDE_PREFIX + path.slice(home.length + 1);
+       return lastPathSegment(path);
+}
+
+/**
+ * Replace a leading `home` prefix in `path` with `~`. Unlike
+ * abbreviateWorkingDir, paths outside `home` (or an unknown home) are
+ * returned unchanged - used for tool-call path displays where the full
+ * path matters.
+ */
+export function abbreviateHome(path: string, home: string | null | undefined): string {
+       if (!home) return path;
+       if (path === home) return HOME_TILDE;
+       if (path.startsWith(home + PATH_SEPARATOR))
+               return HOME_TILDE_PREFIX + path.slice(home.length + 1);
+       return path;
+}
+
+export { CWD_CHANGED_PREFIX, CWD_CLEARED_TEXT } from '$lib/constants';
+
+export interface CwdMessageInfo {
+       // absolute server-side path, null when the cwd was cleared
+       path: string | null;
+       // display form shown in the UI (e.g. ~/Documents)
+       display: string;
+}
+
+/**
+ * Format a synthetic cwd-change message. The text mirrors what the UI
+ * renders for it; the path travels as `[file:///abs/path](display)` so
+ * both the absolute and the short form are visible to the model and
+ * parseable back by the UI.
+ */
+export function formatCwdMessage(cwd: string, home: string | null): string {
+       const display = abbreviateWorkingDir(cwd, home);
+       return `${CWD_CHANGED_PREFIX}[${FILE_URI_PREFIX}${cwd}](${display}).`;
+}
+
+/**
+ * Parse a synthetic cwd message back into its parts. The caller must already
+ * know the message is synthetic (via the persisted `isSynthetic` flag); this
+ * only extracts the path from the message text. Returns null when `content`
+ * is not a cwd message.
+ */
+export function parseCwdMessage(content: string): CwdMessageInfo | null {
+       const trimmed = content.trim();
+       if (trimmed === CWD_CLEARED_TEXT) {
+               return { path: null, display: '' };
+       }
+       if (trimmed.startsWith(CWD_CHANGED_PREFIX)) {
+               const rest = trimmed.slice(CWD_CHANGED_PREFIX.length);
+               // not anchored to the end: guidance may follow the link
+               const link = rest.match(CWD_LINK_REGEX);
+               if (link) return { path: link[1], display: link[2] };
+               return { path: rest, display: rest };
+       }
+       return null;
+}
diff --git a/tools/ui/src/lib/utils/working-directory.ts b/tools/ui/src/lib/utils/working-directory.ts
new file mode 100644 (file)
index 0000000..55fa6ef
--- /dev/null
@@ -0,0 +1,151 @@
+/**
+ * Pure helpers for the working-directory picker search.
+ *
+ * The picker is backed by the server's `file_glob_search` built-in tool.
+ * Queries that start from a root (`/`, `C:\`, `\\host\share`) or from `~`
+ * navigate the directory tree (search the parent for the last segment);
+ * anything else glob-matches home-relative entries. Paths are carried with
+ * `/` separators, which is what the server returns and what Windows accepts.
+ * These helpers build the glob, normalize results and rank them
+ * client-side; the component owns the network/state plumbing.
+ */
+
+import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
+import { TRAILING_SLASHES_REGEX } from '$lib/constants/url';
+import {
+       DRIVE_PREFIX_REGEX,
+       DRIVE_ROOT_REGEX,
+       GLOB_RANGE_CLOSE,
+       GLOB_RANGE_OPEN,
+       GLOB_SPECIAL_CHARS,
+       GLOB_WILDCARD,
+       HOME_TILDE,
+       LEADING_SLASHES_REGEX,
+       UNC_ROOT_REGEX,
+       WINDOWS_SEPARATOR
+} from '$lib/constants';
+import { lastPathSegment } from './path-display';
+
+export interface GlobEntry {
+       path: string;
+       type: string;
+}
+
+export interface PathQuery {
+       parent: string;
+       last: string;
+}
+
+/**
+ * Rewrite `\` into `/` when the query carries a Windows root. Elsewhere the
+ * backslash is left alone: it is a legal filename character on POSIX.
+ */
+function toPosixSeparators(query: string): string {
+       if (!DRIVE_PREFIX_REGEX.test(query) && !query.startsWith(WINDOWS_SEPARATOR)) return query;
+       return query.split(WINDOWS_SEPARATOR).join(PATH_SEPARATOR);
+}
+
+/**
+ * Length of the root prefix of `path`, or 0 when it has none. Covers the
+ * POSIX root, a Windows drive (`C:/`) and a UNC share (`//host/share/`).
+ */
+export function rootPrefixLength(path: string): number {
+       const unc = path.match(UNC_ROOT_REGEX);
+       if (unc) return unc[0].length;
+       const drive = path.match(DRIVE_ROOT_REGEX);
+       if (drive) return drive[0].length;
+       return path.startsWith(PATH_SEPARATOR) ? PATH_SEPARATOR.length : 0;
+}
+
+/** A query starting from a root or from `~` is path navigation, not a home-relative glob. */
+export function splitPathQuery(query: string): PathQuery | null {
+       const normalized = toPosixSeparators(query);
+       const rootLength = rootPrefixLength(normalized);
+       if (rootLength === 0 && !normalized.startsWith(HOME_TILDE)) return null;
+
+       // a root keeps its trailing separator so it stays absolute on its own
+       const root =
+               rootLength > 0
+                       ? normalized.slice(0, rootLength).replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR
+                       : HOME_TILDE;
+
+       const rest = normalized
+               .slice(rootLength > 0 ? rootLength : HOME_TILDE.length)
+               .replace(LEADING_SLASHES_REGEX, '')
+               .replace(TRAILING_SLASHES_REGEX, '');
+
+       const parentOf = (dirs: string) =>
+               rootLength > 0 ? root + dirs : HOME_TILDE + PATH_SEPARATOR + dirs;
+
+       if (!rest) return { parent: root, last: '' };
+
+       const idx = rest.lastIndexOf(PATH_SEPARATOR);
+       if (idx === -1) return { parent: root, last: rest };
+       return { parent: parentOf(rest.slice(0, idx)), last: rest.slice(idx + 1) };
+}
+
+/** Build a case-insensitive glob that matches `query` anywhere within a name. */
+export function buildCaseInsensitiveGlob(query: string): string {
+       let out = GLOB_WILDCARD;
+       for (const c of query) {
+               const lo = c.toLowerCase();
+               const up = c.toUpperCase();
+               if (lo !== up) out += GLOB_RANGE_OPEN + lo + up + GLOB_RANGE_CLOSE;
+               // glob metacharacters are escaped into a literal character class so a
+               // query like "a*b" matches a literal '*' instead of becoming "ab"
+               else if (GLOB_SPECIAL_CHARS.includes(c)) out += GLOB_RANGE_OPEN + c + GLOB_RANGE_CLOSE;
+               else out += c;
+       }
+       return out + GLOB_WILDCARD;
+}
+
+/** Exact basename first, then prefix, then substring; lower is better. */
+const RANK_EXACT = 0;
+const RANK_PREFIX = 1;
+const RANK_SUBSTRING = 2;
+const RANK_OTHER = 3;
+
+function rankScore(path: string, query: string): number {
+       const name = lastPathSegment(path).toLowerCase();
+       const q = query.toLowerCase();
+       if (name === q) return RANK_EXACT;
+       if (name.startsWith(q)) return RANK_PREFIX;
+       if (name.includes(q)) return RANK_SUBSTRING;
+       return RANK_OTHER;
+}
+
+/** Sort entries by relevance, then shorter path, then alphabetically. */
+export function rankEntries(entries: GlobEntry[], query: string): GlobEntry[] {
+       return [...entries].sort(
+               (a, b) =>
+                       rankScore(a.path, query) - rankScore(b.path, query) ||
+                       a.path.length - b.path.length ||
+                       a.path.localeCompare(b.path)
+       );
+}
+
+/** Join a base path and a relative segment, avoiding duplicate slashes. */
+export function joinPath(base: string, rel: string): string {
+       if (!base) return rel;
+       return base.replace(TRAILING_SLASHES_REGEX, '') + PATH_SEPARATOR + rel;
+}
+
+/** Split `text` into alternating segments at each case-insensitive `query` match. */
+export function highlightMatch(text: string, query: string): { text: string; match: boolean }[] {
+       if (!query) return [{ text, match: false }];
+       const segments: { text: string; match: boolean }[] = [];
+       const lowerText = text.toLowerCase();
+       const lowerQuery = query.toLowerCase();
+       let i = 0;
+       while (i < text.length) {
+               const idx = lowerText.indexOf(lowerQuery, i);
+               if (idx < 0) {
+                       segments.push({ text: text.slice(i), match: false });
+                       break;
+               }
+               if (idx > i) segments.push({ text: text.slice(i, idx), match: false });
+               segments.push({ text: text.slice(idx, idx + query.length), match: true });
+               i = idx + query.length;
+       }
+       return segments;
+}
index 9c54d979114396b421de3ab75543062dd7de348b..1e15451de058ccb5c4a4449dfcab53e9ba56fc23 100644 (file)
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
 import { AgenticSectionType, BuiltInTool } from '$lib/enums';
 import type { AgenticSection } from '$lib/utils';
 import { parseToolArgs } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
+import { lastPathSegment, abbreviateHome, formatCwdMessage, parseCwdMessage } from '$lib/utils';
 import {
        parseWriteFileMeta,
        type WriteFileMeta
@@ -27,6 +28,90 @@ function makeSection(
        };
 }
 
+describe('lastPathSegment', () => {
+       it('returns the last segment of an absolute path', () => {
+               expect(lastPathSegment('/Users/me/code/my-project')).toBe('my-project');
+       });
+
+       it('returns the last segment of a tilde-relative path', () => {
+               expect(lastPathSegment('~/git/llama.brand')).toBe('llama.brand');
+       });
+
+       it('strips trailing slashes', () => {
+               expect(lastPathSegment('/foo/bar/')).toBe('bar');
+       });
+
+       it('strips multiple trailing slashes', () => {
+               expect(lastPathSegment('/foo/bar///')).toBe('bar');
+       });
+
+       it('returns the input unchanged when there is no slash', () => {
+               expect(lastPathSegment('project')).toBe('project');
+       });
+
+       it('returns tilde when only tilde is given', () => {
+               expect(lastPathSegment('~/')).toBe('~');
+       });
+});
+
+describe('abbreviateHome', () => {
+       it('abbreviates paths under home with a tilde', () => {
+               expect(abbreviateHome('/Users/al/Documents/x.txt', '/Users/al')).toBe('~/Documents/x.txt');
+       });
+
+       it('abbreviates home itself to a bare tilde', () => {
+               expect(abbreviateHome('/Users/al', '/Users/al')).toBe('~');
+       });
+
+       it('returns paths outside home unchanged', () => {
+               expect(abbreviateHome('/opt/project', '/Users/al')).toBe('/opt/project');
+       });
+
+       it('does not abbreviate a mere prefix match', () => {
+               expect(abbreviateHome('/Users/alice/x', '/Users/al')).toBe('/Users/alice/x');
+       });
+
+       it('returns the path unchanged when home is unknown', () => {
+               expect(abbreviateHome('/Users/al/Documents', null)).toBe('/Users/al/Documents');
+       });
+});
+
+describe('formatCwdMessage / parseCwdMessage', () => {
+       it('formats a cwd change matching the UI text, with a file link', () => {
+               expect(formatCwdMessage('/Users/al/Documents', '/Users/al')).toBe(
+                       'Set working directory to [file:///Users/al/Documents](~/Documents).'
+               );
+       });
+
+       it('falls back to the basename display when home is unknown', () => {
+               expect(formatCwdMessage('/opt/project', null)).toBe(
+                       'Set working directory to [file:///opt/project](project).'
+               );
+       });
+
+       it('round-trips through the parser', () => {
+               const info = parseCwdMessage(formatCwdMessage('/Users/al/Documents', '/Users/al'));
+               expect(info?.path).toBe('/Users/al/Documents');
+               expect(info?.display).toBe('~/Documents');
+       });
+
+       it('parses a cwd message even when guidance follows the link', () => {
+               expect(
+                       parseCwdMessage(
+                               'Set working directory to [file:///a/b](~/b). Tool calls run with this as their working directory.'
+                       )
+               ).toEqual({ path: '/a/b', display: '~/b' });
+       });
+
+       it('parses the cleared marker', () => {
+               expect(parseCwdMessage('Working directory cleared')).toEqual({ path: null, display: '' });
+       });
+
+       it('returns null for non-cwd content', () => {
+               expect(parseCwdMessage('hello there')).toBeNull();
+       });
+});
+
 describe('parseToolArgs (shared)', () => {
        it('returns null when the section has no toolArgs', () => {
                const result = parseToolArgs(BuiltInTool.READ_FILE, makeSection({ toolArgs: undefined }));
diff --git a/tools/ui/tests/unit/working-directory.test.ts b/tools/ui/tests/unit/working-directory.test.ts
new file mode 100644 (file)
index 0000000..f7858e2
--- /dev/null
@@ -0,0 +1,126 @@
+import { describe, expect, it } from 'vitest';
+import {
+       splitPathQuery,
+       buildCaseInsensitiveGlob,
+       rankEntries,
+       joinPath,
+       highlightMatch
+} from '$lib/utils';
+
+describe('splitPathQuery', () => {
+       it('treats a plain query as a home-relative glob (not navigation)', () => {
+               expect(splitPathQuery('docs')).toBeNull();
+       });
+
+       it('navigates the root for `/`', () => {
+               expect(splitPathQuery('/')).toEqual({ parent: '/', last: '' });
+       });
+
+       it('navigates home for `~`', () => {
+               expect(splitPathQuery('~')).toEqual({ parent: '~', last: '' });
+       });
+
+       it('splits an absolute path into parent and last segment', () => {
+               expect(splitPathQuery('/Users/al/proj')).toEqual({ parent: '/Users/al', last: 'proj' });
+       });
+
+       it('navigates a Windows drive path written with backslashes', () => {
+               expect(splitPathQuery('C:\\repos\\llama.cpp')).toEqual({
+                       parent: 'C:/repos',
+                       last: 'llama.cpp'
+               });
+       });
+
+       it('navigates a Windows drive path written with forward slashes', () => {
+               expect(splitPathQuery('D:/repos')).toEqual({ parent: 'D:/', last: 'repos' });
+       });
+
+       it('treats a bare drive as its root', () => {
+               expect(splitPathQuery('D:')).toEqual({ parent: 'D:/', last: '' });
+               expect(splitPathQuery('D:\\')).toEqual({ parent: 'D:/', last: '' });
+       });
+
+       it('navigates a UNC share', () => {
+               expect(splitPathQuery('\\\\host\\share\\proj')).toEqual({
+                       parent: '//host/share/',
+                       last: 'proj'
+               });
+       });
+
+       it('keeps a backslash as a POSIX filename character', () => {
+               expect(splitPathQuery('/tmp/a\\b')).toEqual({ parent: '/tmp', last: 'a\\b' });
+       });
+
+       it('splits a home-relative path into parent and last segment', () => {
+               expect(splitPathQuery('~/Documents')).toEqual({ parent: '~', last: 'Documents' });
+       });
+
+       it('strips trailing slashes before splitting', () => {
+               expect(splitPathQuery('/Users/al/')).toEqual({ parent: '/Users', last: 'al' });
+       });
+
+       it('handles a single-segment absolute path', () => {
+               expect(splitPathQuery('/opt')).toEqual({ parent: '/', last: 'opt' });
+       });
+});
+
+describe('buildCaseInsensitiveGlob', () => {
+       it('wraps letters in case-insensitive character classes', () => {
+               expect(buildCaseInsensitiveGlob('ab')).toBe('*[aA][bB]*');
+       });
+
+       it('escapes glob metacharacters into literal fragments', () => {
+               expect(buildCaseInsensitiveGlob('a*b')).toBe('*[aA][*][bB]*');
+       });
+});
+
+describe('rankEntries', () => {
+       const entries = [
+               { path: '/h/README', type: 'dir' },
+               { path: '/h/read', type: 'dir' },
+               { path: '/h/readme.txt', type: 'dir' }
+       ];
+
+       it('ranks exact basename match first', () => {
+               const ranked = rankEntries(entries, 'read');
+               expect(ranked[0].path).toBe('/h/read');
+       });
+
+       it('breaks ties by shorter path, then alphabetically', () => {
+               const ranked = rankEntries(entries, 'read');
+               expect(ranked[ranked.length - 1].path).toBe('/h/readme.txt');
+       });
+
+       it('does not mutate the input', () => {
+               const snapshot = [...entries];
+               rankEntries(entries, 'read');
+               expect(entries).toEqual(snapshot);
+       });
+});
+
+describe('joinPath', () => {
+       it('joins base and relative avoiding a double slash', () => {
+               expect(joinPath('/home/al/', 'docs')).toBe('/home/al/docs');
+       });
+
+       it('returns the relative path when base is empty', () => {
+               expect(joinPath('', 'docs')).toBe('docs');
+       });
+});
+
+describe('highlightMatch', () => {
+       it('returns a single non-matching segment when query is empty', () => {
+               expect(highlightMatch('abc', '')).toEqual([{ text: 'abc', match: false }]);
+       });
+
+       it('marks every case-insensitive occurrence of the query', () => {
+               expect(highlightMatch('aXa', 'ax')).toEqual([
+                       { text: 'aX', match: true },
+                       { text: 'a', match: false }
+               ]);
+       });
+
+       it('returns non-matching text when the query is absent', () => {
+               expect(highlightMatch('abc', 'z')).toEqual([{ text: 'abc', match: false }]);
+       });
+});