From 2f56fc3431f47fe042bf3825e4d5523bdddda993 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Tue, 4 Aug 2026 19:05:48 +0200 Subject: [PATCH] ui: CWD for agent (#26518) * server : extend file_glob_search for UI pickers * ui : add per-conversation working directory with picker * ui : add path navigation and search scope to cwd picker Treat path-like queries (starting with / or ~) as directory navigation instead of glob-matching the whole query: search the parent for the last segment, and descend into an exactly-typed directory by listing its children. Show the effective search scope in the footer and auto-search on open so the current directory and its siblings appear immediately. Assisted-by: Claude * db : persist per-call tool cwd on tool result messages * ui : abbreviate tool paths under home with a tilde * ui : show the per-call cwd on exec shell rows * ui : clarify the synthetic cwd message for the model * ui : reuse the trailing cwd row on a repeated pick * ui : don't jump when a cwd row is injected mid-chat * chore: Formatting * refactor: Cleanup comments * ui : unify working directory naming and add a synthetic-message flag * ui : render synthetic cwd rows without a scroll jump * ui : decouple the working directory picker into utils and sub-components * ui : add get_info tool call block * chore: Formatting * refactor: Cleanup * refactor: Cleanup * refactor: Cleanup * fix: UI * server : harden file_glob_search listing (kind enum, timeout, symlink guard, absolute base) * ui : use persisted isSynthetic flag for cwd rows, drop legacy formats * ui : cache picker search, fail visibly on native resolve * ui : escape glob metacharacters in picker search glob * ui : simplify auto-scroll pin * chore: Format * fix: Use `SvelteMap` * refactor: Post-review fixes * ui: accept Windows roots in the working directory picker recognize a drive root (C:) and a UNC share (//host/share) as path navigation, alongside the POSIX root and ~, so a query like D:\repos lists that directory instead of glob-matching it under the home dir split below the root, so a bare drive resolves to its root rather than to a drive-relative prefix rewrite backslashes into forward slashes only when the query carries a Windows root, since a backslash is a legal POSIX filename character paths keep travelling with forward slashes, which is what the server returns and what Windows accepts --------- Co-authored-by: Pascal --- tools/server/server-tools.cpp | 258 +++++++--- tools/server/tests/unit/test_tools_builtin.py | 98 ++++ tools/ui/src/app.d.ts | 9 + .../app/chat/ChatForm/ChatForm.svelte | 35 +- .../ChatForm/ChatFormWorkingDirectory.svelte | 479 ++++++++++++++++++ .../ChatFormWorkingDirectoryChip.svelte | 69 +++ ...ChatFormWorkingDirectoryResultsList.svelte | 72 +++ .../ChatMessage/ChatMessage.svelte | 21 +- .../ChatMessage/ChatMessageCwdChange.svelte | 31 ++ .../ChatMessage/ChatMessageSynthetic.svelte | 23 + .../ChatMessageToolCallBlock.svelte | 3 + .../ChatMessageToolCallBlockEditFile.svelte | 8 +- ...essageToolCallBlockExecShellCommand.svelte | 32 ++ ...tMessageToolCallBlockFileGlobSearch.svelte | 8 +- .../ChatMessageToolCallBlockGetInfo.svelte | 69 +++ .../ChatMessageToolCallBlockGrepSearch.svelte | 6 +- .../ChatMessageToolCallBlockWriteFile.svelte | 8 +- tools/ui/src/lib/components/app/chat/index.ts | 26 + tools/ui/src/lib/constants/built-in-tools.ts | 2 + tools/ui/src/lib/constants/index.ts | 2 + tools/ui/src/lib/constants/path-display.ts | 22 + tools/ui/src/lib/constants/tools.ts | 3 + .../ui/src/lib/constants/working-directory.ts | 40 ++ tools/ui/src/lib/enums/index.ts | 8 +- tools/ui/src/lib/enums/tools.enums.ts | 11 + tools/ui/src/lib/enums/ui.enums.ts | 1 + tools/ui/src/lib/services/database.service.ts | 3 +- tools/ui/src/lib/services/tools.service.ts | 33 +- tools/ui/src/lib/stores/agentic.svelte.ts | 9 +- tools/ui/src/lib/stores/chat.svelte.ts | 64 ++- .../ui/src/lib/stores/conversations.svelte.ts | 57 ++- tools/ui/src/lib/stores/tools.svelte.ts | 38 +- tools/ui/src/lib/types/agentic.d.ts | 3 +- tools/ui/src/lib/types/chat.d.ts | 3 +- tools/ui/src/lib/types/database.d.ts | 5 + tools/ui/src/lib/utils/agentic.ts | 4 + tools/ui/src/lib/utils/index.ts | 23 + tools/ui/src/lib/utils/path-display.ts | 93 ++++ tools/ui/src/lib/utils/working-directory.ts | 151 ++++++ tools/ui/tests/unit/tool-calls.test.ts | 85 ++++ tools/ui/tests/unit/working-directory.test.ts | 126 +++++ 41 files changed, 1946 insertions(+), 95 deletions(-) create mode 100644 tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryResultsList.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageCwdChange.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSynthetic.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockGetInfo.svelte create mode 100644 tools/ui/src/lib/constants/path-display.ts create mode 100644 tools/ui/src/lib/constants/working-directory.ts create mode 100644 tools/ui/src/lib/utils/path-display.ts create mode 100644 tools/ui/src/lib/utils/working-directory.ts create mode 100644 tools/ui/tests/unit/working-directory.test.ts diff --git a/tools/server/server-tools.cpp b/tools/server/server-tools.cpp index 984bb478ea..e050d03b52 100644 --- a/tools/server/server-tools.cpp +++ b/tools/server/server-tools.cpp @@ -10,8 +10,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -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 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_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 list_files(const std::string & base, std::string & err) const override { + std::vector list_entries(const std::string & base, const std::string & caller_path, int max_depth, list_kind kind, std::string & err, bool & truncated) const override { err.clear(); - std::string abs_base = resolve(base); - if (!is_directory(base)) { - err = "path does not exist or is not a directory: " + base; + truncated = false; + std::error_code ec; + if (!fs::is_directory(base, ec) || ec) { + err = "path does not exist or is not a directory: " + caller_path; return {}; } - auto res = run( - {"git", "-C", abs_base, "ls-files", "--cached", "--others", "--exclude-standard"}, - SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_GIT_LS_FILES_TIMEOUT); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(SERVER_TOOL_LIST_ENTRIES_TIMEOUT); - if (res.exit_code == 0 && !res.timed_out) { - std::vector result; - std::istringstream iss(res.output); - std::string line; - while (std::getline(iss, line)) { - if (!line.empty() && line.back() == '\r') line.pop_back(); - if (line.empty()) continue; - std::replace(line.begin(), line.end(), '\\', '/'); - if (is_regular_file((fs::path(base) / line).string())) { - result.push_back(line); + // git ls-files cannot list directories; use the walker when they are requested + if (kind == list_kind::files) { + auto res = run( + {"git", "-C", base, "ls-files", "--cached", "--others", "--exclude-standard"}, + SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_LIST_ENTRIES_TIMEOUT); + + if (res.exit_code == 0 && !res.timed_out) { + std::vector 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 & junk_dir_names() { static const std::unordered_set names = { ".git", ".svn", ".hg", "node_modules", "__pycache__", @@ -227,28 +286,50 @@ private: return names; } - std::vector list_files_fallback(const std::string & base) const { - std::vector result; + std::vector 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 result; std::error_code ec; - std::vector> stack; - stack.emplace_back(fs::path(base), fs::path()); + std::vector> 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 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()); + // 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(), max_depth, kind, err, truncated); if (!err.empty()) { return {{"error", err}}; } - std::vector 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 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> 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 diff --git a/tools/server/tests/unit/test_tools_builtin.py b/tools/server/tests/unit/test_tools_builtin.py index fb194cac66..e713758d91 100755 --- a/tools/server/tests/unit/test_tools_builtin.py +++ b/tools/server/tests/unit/test_tools_builtin.py @@ -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"] diff --git a/tools/ui/src/app.d.ts b/tools/ui/src/app.d.ts index 5264e5cc4d..b9484d9503 100644 --- a/tools/ui/src/app.d.ts +++ b/tools/ui/src/app.d.ts @@ -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; } } diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte index 85683908cc..105e414fe7 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -6,6 +6,7 @@ ChatFormMcpResourcesList, ChatFormPickers, ChatFormTextarea, + ChatFormWorkingDirectory, DialogMcpResourcesBrowser } from '$lib/components/app'; import { @@ -31,7 +32,13 @@ import { chatStore } from '$lib/stores/chat.svelte'; import { mcpStore } from '$lib/stores/mcp.svelte'; import { mcpHasResourceAttachments } from '$lib/stores/mcp-resources.svelte'; - import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte'; + import { toolsStore } from '$lib/stores/tools.svelte'; + import { + conversationsStore, + activeMessages, + activeConversation, + pendingCwd + } from '$lib/stores/conversations.svelte'; import type { GetPromptResult, MCPPromptInfo, MCPResourceInfo, PromptMessage } from '$lib/types'; import { isIMEComposing, parseClipboardContent, uuid } from '$lib/utils'; import { @@ -107,6 +114,15 @@ let isInlineResourcePickerOpen = $state(false); let resourceSearchQuery = $state(''); + let cwd = $derived(activeConversation()?.cwd ?? pendingCwd()); + + async function handleWorkingDirectoryChange(value: string | null) { + await conversationsStore.setCwd(value); + if (conversationsStore.activeConversation) { + await chatStore.recordCwdChange(value?.trim() || null); + } + } + // Resource Dialog State let isResourceDialogOpen = $state(false); let preSelectedResourceUri = $state(undefined); @@ -155,6 +171,12 @@ audioRecorder = new AudioRecorder(); }); + // Defer so the closing popover's focus scope tears down first - bits-ui + // yanks a synchronous focus() back into the still-mounted popover. + function refocusInput() { + queueMicrotask(() => textareaRef?.focus()); + } + export function focus() { textareaRef?.focus(); } @@ -470,7 +492,7 @@
{ event.preventDefault(); @@ -559,6 +581,15 @@ + + {#if toolsStore.builtinTools.length > 0} + + {/if} + 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([]); + let isSearching = $state(false); + let searchError = $state(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(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(); + + 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 { + 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); + + +
+ + + + + + event.preventDefault()} + > +
+ + + {#if inputValue.trim() && (isSearching || queryResults.length > 0 || searchError)} + (hoveredIndex = index)} + /> + {/if} + + {#if pickerSupported} + + {/if} + + {#if homeBase} + + + + Searching in: + + {abbreviateHome(searchScope, homeBase)} + + {/if} +
+
+
+
+ + 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 index 0000000000..4f8d0f7f7d --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte @@ -0,0 +1,69 @@ + + + +
+ + + {#if showTooltip && displayLabelTitle} + + + {#snippet child({ props })} + {displayLabel} + {/snippet} + + +

{displayLabelTitle}

+
+
+ {:else} + {displayLabel} + {/if} +
+ + {#if directory} +
+ +
+ {/if} +
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 index 0000000000..d62eb88242 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryResultsList.svelte @@ -0,0 +1,72 @@ + + +
+ {#if isSearching && results.length === 0} +
Searching...
+ {:else if error} +
{error}
+ {:else if results.length === 0} +
No matching folders
+ {:else} + {#each results as path, index (path)} + + {/each} + {/if} +
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte index b8068f7907..afe90f66fe 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte @@ -12,6 +12,7 @@ ChatMessageAssistant, ChatMessageUser, ChatMessageSystem, + ChatMessageSynthetic, ChatMessageMcpPrompt } from '$lib/components/app/chat'; import { parseFilesToMessageExtras } from '$lib/utils/browser-only'; @@ -56,6 +57,10 @@ : message.content ); + // Synthetic cwd-change messages render with the folder-row UI instead + // of a user bubble. The persisted flag is the single source of truth. + let isSynthetic = $derived(Boolean(message.isSynthetic)); + let rawEditContent = $derived.by(() => { if (message.role !== MessageRole.ASSISTANT) return undefined; @@ -344,7 +349,7 @@ } -
+
{#if message.role === MessageRole.SYSTEM} + {:else if isSynthetic} + {:else if message.role === MessageRole.USER} 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 index 0000000000..0b0133060f --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageCwdChange.svelte @@ -0,0 +1,31 @@ + + +{#if info} +
+ {#if info.path === null} + + Working directory cleared + {:else} + + Set working directory to  + + {info.display} + + {/if} +
+{/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 index 0000000000..1597df2ab1 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageSynthetic.svelte @@ -0,0 +1,23 @@ + + +{#if isCwdChange} + +{:else} + {message.content} +{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte index b1daedfc81..1d6cccc9f3 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlock.svelte @@ -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 @@ {:else if section.toolName === BuiltInTool.GET_DATETIME} +{:else if section.toolName === BuiltInTool.GET_INFO} + {:else if section.toolName === BuiltInTool.READ_FILE} {:else if section.toolName === BuiltInTool.EDIT_FILE} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte index b990c3898b..f8618864c9 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte @@ -1,7 +1,8 @@ {#snippet execShellTitle()} + {#if cwd} + {wdDisplay} + $ + {/if} + {#if highlightedCommandHtml} {@html highlightedCommandHtml} {:else} @@ -232,6 +247,23 @@