From 2f56fc3431f47fe042bf3825e4d5523bdddda993 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Tue, 4 Aug 2026 19:05:48 +0200 Subject: [PATCH 001/210] 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 @@ diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte index 1debbe8e03..07cbaf492c 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte @@ -2,7 +2,7 @@ import { File, Folder } from '@lucide/svelte'; import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils'; import { toolsStore } from '$lib/stores/tools.svelte'; - import { BuiltInTool, FileMentionEntryType, GlobSearchType } from '$lib/enums'; + import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums'; import { isMobile } from '$lib/stores/viewport.svelte'; import { config } from '$lib/stores/settings.svelte'; import * as Popover from '$lib/components/ui/popover'; @@ -162,6 +162,17 @@ } export function handleKeydown(event: KeyboardEvent): boolean { + // Always consume Enter while the picker is open - even with no + // result yet (skeletons) or no matches - so the chat form's + // Enter-to-submit never fires mid-search. + if (isOpen && event.key === KeyboardKey.ENTER) { + event.preventDefault(); + if (nav.hoveredIndex >= 0 && displayedItems[nav.hoveredIndex]) { + handleSelect(displayedItems[nav.hoveredIndex]); + } + return true; + } + return nav.handleKeydown(event); } diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte index ab297af4f0..8ef56d7ee7 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormTextarea.svelte @@ -48,7 +48,8 @@ } } - // Plain-text caret offsets for the picker/paste/mention-splice flows. + // Plain-text caret offsets, shared with the contenteditable variant so + // the picker/paste flows can address either renderer through one handle. export function getCaretOffset(): number { if (!textareaElement) return 0; return textareaElement.selectionStart ?? textareaElement.value.length; diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts index b7a2eff5de..04b1d915f6 100644 --- a/tools/ui/src/lib/components/app/chat/index.ts +++ b/tools/ui/src/lib/components/app/chat/index.ts @@ -120,7 +120,8 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/ * Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing. * * **Architecture:** - * - Composes ChatFormTextarea, ChatFormActions, and ChatFormPickerMcpPrompts + * - Composes ChatFormTextarea (or ChatFormContenteditable for messages with + * file mention links), ChatFormActions, and ChatFormPickerMcpPrompts * - Manages file upload state via `uploadedFiles` bindable prop * - Integrates with ModelsSelectorDropdown for model selection in router mode * - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.) @@ -266,9 +267,16 @@ export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileIn export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte'; /** - * Auto-resizing textarea with IME composition support. Mention links stay - * plain markdown text in the input; the chip rendering happens in the - * message view via the rehype file-badge plugin. + * Auto-resizing contenteditable input that renders `[name](file://...)` + * mention links as inline chips while keeping the value as the markdown + * source string. ChatForm swaps it in once a mention link lands in the + * buffer. Shares the focus()/resetHeight()/caret handle with the textarea. + */ +export { default as ChatFormContenteditable } from './ChatForm/ChatFormContenteditable.svelte'; + +/** + * Plain auto-resizing textarea with IME composition support. Default input + * renderer inside ChatForm until a file mention lands. */ export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte'; diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte index be6041b63c..6ea4ac78e6 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte @@ -34,7 +34,8 @@ preprocessLaTeX, getImageErrorFallbackHtml, copyCodeToClipboard, - copyToClipboard + copyToClipboard, + splitGluedClosingCodeFences } from '$lib/utils'; import { IMAGE_NOT_ERROR_BOUND_SELECTOR, @@ -342,7 +343,11 @@ * Incomplete code blocks are rendered using SyntaxHighlightedCode to maintain interactivity. * @param markdown - The raw markdown string to process */ - async function processMarkdown(markdown: string) { + async function processMarkdown(rawMarkdown: string) { + // Text glued to a closing code fence is not a fence to the parser - + // the block would swallow it. Split it onto its own line first. + const markdown = splitGluedClosingCodeFences(rawMarkdown); + // Early exit if content unchanged (can happen with rapid coalescing) if (markdown === previousContent) { return; diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css index 41813f4fda..cada489ca9 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/markdown-content.css @@ -243,7 +243,6 @@ div.markdown-user-content :global(.table-wrapper) { /* Code blocks */ .markdown-content :global(.code-block-wrapper) { - margin: 1.5rem 0; border-radius: 0.75rem; overflow: hidden; border: 1px solid color-mix(in oklch, var(--border) 30%, transparent); @@ -253,6 +252,14 @@ div.markdown-user-content :global(.table-wrapper) { max-height: var(--max-message-height); } +.markdown-content .markdown-block:not(:first-child) :global(.code-block-wrapper) { + margin-top: 1rem; +} + +.markdown-content .markdown-block:not(:last-child) :global(.code-block-wrapper) { + margin-bottom: 1rem; +} + .markdown-content:global(.dark) :global(.code-block-wrapper) { border-color: color-mix(in oklch, var(--border) 20%, transparent); } diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts index 4459e2ba73..9f9c59fc3b 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts @@ -1,7 +1,7 @@ /** * Rehype plugin that rewrites `file://` markdown anchors into the inline - * @-mention chip, reusing the visual contract from - * `$lib/constants/mention-badge`. + * mention chip, sharing the class string with the contenteditable + * tokenizer via `$lib/constants/mention-badge`. * * The chip is presentational: `file://` navigation is blocked from * http(s) pages, so the anchor becomes a plain `` (no link role, diff --git a/tools/ui/src/lib/constants/css-classes.ts b/tools/ui/src/lib/constants/css-classes.ts index 095b82e414..4e3310544c 100644 --- a/tools/ui/src/lib/constants/css-classes.ts +++ b/tools/ui/src/lib/constants/css-classes.ts @@ -19,8 +19,9 @@ export const PANEL_CLASSES = ` export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80'; export const DIALOG_SUBMENU_CONTENT = 'w-60'; -/** Selects the chat-form input to restore focus after model actions. */ -export const CHAT_INPUT_FOCUS_SELECTOR = '[data-slot="input-area"] textarea'; +/** Selects the focused chat-form input (either renderer) to restore focus after model actions. */ +export const CHAT_INPUT_FOCUS_SELECTOR = + '[data-slot="input-area"] textarea, [data-slot="input-area"] [contenteditable="true"]'; /** Default Tailwind size class for inline icon components (lucide, etc.). */ export const ICON_CLASS_DEFAULT = 'h-4 w-4'; diff --git a/tools/ui/src/lib/constants/mention-badge.ts b/tools/ui/src/lib/constants/mention-badge.ts index b0026eb339..d60727ba25 100644 --- a/tools/ui/src/lib/constants/mention-badge.ts +++ b/tools/ui/src/lib/constants/mention-badge.ts @@ -1,8 +1,9 @@ /** - * Visual contract for message @-mention badges. Svelte cannot be mounted - * from a hast tree, so the rehype file-badge plugin emits the shared class - * string below; keeping it here as a literal lets Tailwind's source - * scanner generate the utility classes. + * Shared visual contract between the two DOM-only badge paths (the + * contenteditable tokenizer + the rehype plugin). Svelte cannot be + * mounted at the per-keystroke tokenizer hot path nor from a hast tree, + * so both emit the badge with the same class string literal; Tailwind's + * scanner picks it up in both sources. */ export const MENTION_BADGE_CLASSNAME = 'inline-flex w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md border border-border/50 bg-foreground/5 px-1.5 py-0.5 text-xs font-mono text-foreground hover:bg-foreground/10 dark:bg-foreground/10 dark:text-secondary-foreground'; @@ -10,8 +11,10 @@ export const MENTION_BADGE_CLASSNAME = export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0'; /** - * SVG attributes shared by the hast-built badge icons; the rehype plugin - * spreads them onto the `` `properties`. + * SVG attributes shared by the DOM-built and hast-built badge icons. + * The tokenizer applies them via `setAttribute`, the rehype plugin + * spreads them onto the hast `` `properties`; string values are + * valid for both. */ export const MENTION_BADGE_SVG_ATTRIBUTES: Readonly> = { xmlns: 'http://www.w3.org/2000/svg', diff --git a/tools/ui/src/lib/enums/keyboard.enums.ts b/tools/ui/src/lib/enums/keyboard.enums.ts index 918bc57c5a..735d3e4b46 100644 --- a/tools/ui/src/lib/enums/keyboard.enums.ts +++ b/tools/ui/src/lib/enums/keyboard.enums.ts @@ -8,7 +8,6 @@ export enum KeyboardKey { ARROW_DOWN = 'ArrowDown', ARROW_LEFT = 'ArrowLeft', ARROW_RIGHT = 'ArrowRight', - BACKSPACE = 'Backspace', TAB = 'Tab', B_LOWER = 'b', D_LOWER = 'd', diff --git a/tools/ui/src/lib/utils/code.ts b/tools/ui/src/lib/utils/code.ts index 35f3877f4a..4361f311b7 100644 --- a/tools/ui/src/lib/utils/code.ts +++ b/tools/ui/src/lib/utils/code.ts @@ -17,6 +17,56 @@ export interface IncompleteCodeBlock { openingIndex: number; } +// A fence line: up to 3 leading spaces (CommonMark), 3+ backticks, then +// whatever trails on the same line. +const FENCE_LINE_REGEX = /^ {0,3}(`{3,})(.*)$/; + +/** + * Splits text glued to a closing code fence onto its own line: + * + * ```ts + * let foo = 'bar'; + * ```create this file on ... + * + * A closing fence with trailing text is not a fence to the markdown + * parser, so the block would swallow the text as code. The chat form + * normally keeps the fence on its own line, but older messages and + * hand-pasted content can carry the glued form. + * + * Only trailing text containing whitespace is split: a single word + * after the backticks inside a fenced block is more likely nested + * markdown (a ```python example inside a ```md block) than glued prose. + */ +export function splitGluedClosingCodeFences(markdown: string): string { + if (!markdown.includes('```')) return markdown; + + const lines = markdown.split(NEWLINE); + let inside = false; + let changed = false; + + for (let i = 0; i < lines.length; i++) { + const match = FENCE_LINE_REGEX.exec(lines[i]); + if (!match) continue; + + if (!inside) { + inside = true; + continue; + } + + inside = false; + + const trailing = match[2]; + if (trailing.includes('`') || !/\s/.test(trailing)) continue; + + lines[i] = lines[i].slice(0, lines[i].length - trailing.length); + lines.splice(i + 1, 0, trailing.trim()); + i++; + changed = true; + } + + return changed ? lines.join(NEWLINE) : markdown; +} + /** * Strips empty lines (whitespace-only) from the start and end of code. * diff --git a/tools/ui/src/lib/utils/contenteditable-tokenizer.ts b/tools/ui/src/lib/utils/contenteditable-tokenizer.ts new file mode 100644 index 0000000000..0f136bdbee --- /dev/null +++ b/tools/ui/src/lib/utils/contenteditable-tokenizer.ts @@ -0,0 +1,890 @@ +/** + * Maps between the chat-form contenteditable's markdown source and the + * badge/code/text token stream the DOM is built from. A badge is one + * opaque source contribution (`[name](file://path)`); its own subtree + * is never walked, and the caret cannot land inside it, so offsets + * resolve to the nearest badge edge. Code spans (``) + * are EDITABLE, unlike badges: they carry the full source segment + * (backtick fences included) as their text, so their textContent + * serializes verbatim and source offsets map 1:1 to text offsets. + * + * The tokenizer emits a flat DOM (text nodes + badges + code spans), + * but browsers restructure it on Enter (`
` line wrappers, `
` + * shapes). Serialization folds those back into `\n` so the source + * never diverges from what is on screen; both offset mappers + * understand the same shapes. + * + * The newline separating a fenced block from adjacent content is a + * SOURCE-level concept, never stored in the DOM: the block is + * display:block, so a leading `\n` in the following text node would + * render as a phantom empty line. Serialization synthesizes exactly + * one `\n` at every block boundary and `buildFragment` strips it from + * text tokens. A text node's own leading/trailing `\n` next to a + * block is an ADDITIONAL blank line. + */ + +import { + decodeFileLinkPath, + fileMentionLinkRe, + getMentionBadgeIconPaths, + getMentionBadgeLabel +} from './mention-badge'; +import { + MENTION_BADGE_CLASSNAME, + MENTION_BADGE_ICON_CLASSNAME, + MENTION_BADGE_SVG_ATTRIBUTES, + SETTINGS_KEYS +} from '$lib/constants'; +import { settingsStore } from '$lib/stores/settings.svelte'; +import { toolsStore } from '$lib/stores/tools.svelte'; + +export type ContentToken = + | { kind: 'text'; text: string } + | { kind: 'badge'; name: string; path: string } + | { kind: 'inlineCode'; text: string } + | { kind: 'codeBlock'; text: string }; + +// Block wrappers browsers insert for newlines; each folds back into a +// single `\n` during serialization. +const BLOCK_TAG_NAMES = new Set(['DIV', 'P']); + +// `file://` is required so plain URLs stay as text; `)` terminates only +// when not followed by whitespace or `[` (adjacent badges keep working). +const MENTION_BADGE_RE = fileMentionLinkRe('g'); + +function badgeSourceLength(name: string, path: string): number { + if (!name || !path) return 0; + return `[${name}](file://${path})`.length; +} + +/** + * Recognize complete code spans. Fenced blocks (triple backticks, + * optional language, possibly multiline) take priority over inline + * spans (single backticks, single line, non-empty). Only CLOSED + * spans match: an unclosed fence stays plain text until the closing + * backticks land. The match includes the fences so the token's + * source length equals its rendered text length. + */ +const CODE_SPAN_RE = /(```[\s\S]*?```)|(`[^`\n]+`)/g; + +/** + * Cheap gate check for `ChatForm`: does the buffer contain a + * complete code span (inline or fenced)? Used to promote the plain + * textarea to the contenteditable renderer. + */ +export function containsCodeSpan(value: string): boolean { + CODE_SPAN_RE.lastIndex = 0; + return CODE_SPAN_RE.test(value); +} + +const CODE_FENCE_RE = /```/g; + +/** + * Is `offset` inside a fenced code block region? Toggle-based: an + * odd number of ``` fences before the offset means the position + * sits in block content. Unlike `containsCodeSpan` this also + * counts the still-OPEN fence while the user is typing a block + * (no closing ``` yet), so Enter can add a line instead of + * submitting the message. + */ +export function isOffsetInCodeBlock(source: string, offset: number): boolean { + let inside = false; + CODE_FENCE_RE.lastIndex = 0; + + let match: RegExpExecArray | null; + while ((match = CODE_FENCE_RE.exec(source)) !== null) { + if (match.index + match[0].length > offset) break; + inside = !inside; + } + + return inside; +} + +/** + * Tokenize a markdown source value into the segments the + * contenteditable will render. Code spans are carved out first + * (their content is literal - a `file://` link inside backticks + * must NOT render as a badge), then plain text and badges + * interleave in the remaining gaps. Any whitespace after a badge + * stays in a plain text token so the round trip is byte-exact. + */ +export function tokenizeContent(input: string): ContentToken[] { + const tokens: ContentToken[] = []; + let cursor = 0; + CODE_SPAN_RE.lastIndex = 0; + + let match: RegExpExecArray | null; + while ((match = CODE_SPAN_RE.exec(input)) !== null) { + const start = match.index; + + if (start > cursor) { + pushTextAndBadgeTokens(input.slice(cursor, start), tokens); + } + + tokens.push( + match[1] !== undefined + ? { kind: 'codeBlock', text: match[1] } + : { kind: 'inlineCode', text: match[2] } + ); + cursor = start + match[0].length; + } + + if (cursor < input.length) { + pushTextAndBadgeTokens(input.slice(cursor), tokens); + } + + return tokens; +} + +/** + * Tokenize a code-free segment into text and badge tokens. + */ +function pushTextAndBadgeTokens(input: string, tokens: ContentToken[]) { + let cursor = 0; + MENTION_BADGE_RE.lastIndex = 0; + + let match: RegExpExecArray | null; + while ((match = MENTION_BADGE_RE.exec(input)) !== null) { + const [whole, name, path] = match; + const start = match.index; + + if (start > cursor) { + tokens.push({ kind: 'text', text: input.slice(cursor, start) }); + } + + tokens.push({ kind: 'badge', name, path }); + cursor = start + whole.length; + } + + if (cursor < input.length) { + tokens.push({ kind: 'text', text: input.slice(cursor) }); + } +} + +function isCodeBlockElement(node: Node | null): node is HTMLElement { + return node instanceof HTMLElement && node.dataset.codeToken === 'block'; +} + +/** + * Serialize a contenteditable subtree back to source. `
` and block + * wrappers the browser inserted for newlines fold back into `\n` (a + * trailing `
` is the browser's caret placeholder, not a newline); + * any other element is transparent. Code spans serialize their + * textContent verbatim (fences included). One separator `\n` is + * synthesized at every fenced-block boundary (the DOM never stores + * it), and a `
` adjacent to a code block is an escape hatch, not + * a newline. + */ +export function serializeContent(root: HTMLElement): string { + let out = ''; + let pendingBlockBoundary = false; + + const walk = (parent: Node) => { + let first = true; // no source-contributing sibling seen yet + + for (const child of Array.from(parent.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent ?? ''; + if (text.length > 0) { + if (pendingBlockBoundary) { + out += '\n'; + pendingBlockBoundary = false; + } + out += text; + first = false; + } + continue; + } + + if (child.nodeType !== Node.ELEMENT_NODE) continue; + + const el = child as HTMLElement; + + if (el.dataset.mentionBadge === 'true') { + const name = el.dataset.mentionName ?? ''; + const path = el.dataset.mentionPath ?? ''; + if (name && path) { + if (pendingBlockBoundary) { + out += '\n'; + pendingBlockBoundary = false; + } + out += `[${name}](file://${path})`; + first = false; + } + continue; + } + + if (el.dataset.codeToken !== undefined) { + const isBlock = el.dataset.codeToken === 'block'; + if (isBlock && (pendingBlockBoundary || !first)) out += '\n'; + pendingBlockBoundary = false; + walk(el); + first = false; + if (isBlock) pendingBlockBoundary = true; + continue; + } + + if (el.tagName === 'BR') { + const isHatch = + isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling); + if (!isHatch && el.nextSibling) { + if (pendingBlockBoundary) { + out += '\n'; + pendingBlockBoundary = false; + } + out += '\n'; + first = false; + } + continue; + } + + if (BLOCK_TAG_NAMES.has(el.tagName)) { + if (pendingBlockBoundary || !first) out += '\n'; + pendingBlockBoundary = false; + walk(el); + first = false; + continue; + } + + walk(el); + if (pendingBlockBoundary) first = false; + } + }; + + walk(root); + return out; +} + +/** + * Compare the live DOM's non-text structure against a token stream. + * Only element contributions are compared (badges by name/path, code + * spans by kind and source segment): text nodes are owned by the + * browser between rebuilds, so their split/merge state is irrelevant. + * A mismatch means token boundaries shifted (a code span was just + * completed or broken) and the DOM needs a rebuild to restyle. + */ +export function domMatchesTokens(root: HTMLElement, tokens: ContentToken[]): boolean { + const expected = tokens.filter((token) => token.kind !== 'text'); + let index = 0; + + const walk = (parent: Node): boolean => { + for (const child of Array.from(parent.childNodes)) { + if (child.nodeType !== Node.ELEMENT_NODE) continue; + + const el = child as HTMLElement; + const isBadge = el.dataset.mentionBadge === 'true'; + const isCode = el.dataset.codeToken !== undefined; + + if (!isBadge && !isCode) { + if (!walk(el)) return false; + continue; + } + + const token = expected[index++]; + if (!token) return false; + + if (isBadge) { + if (token.kind !== 'badge') return false; + if (token.name !== (el.dataset.mentionName ?? '')) return false; + if (token.path !== (el.dataset.mentionPath ?? '')) return false; + continue; + } + + const codeKind = el.dataset.codeToken === 'block' ? 'codeBlock' : 'inlineCode'; + if (token.kind !== codeKind) return false; + if ( + (token.kind === 'inlineCode' || token.kind === 'codeBlock') && + token.text !== (el.textContent ?? '') + ) { + return false; + } + } + + return true; + }; + + return walk(root) && index === expected.length; +} + +/** + * Plain-text offset of a `Range` in the root; null range (selection + * lost) falls back to buffer length. Walked against the live DOM (not + * a clone) so a `
` keeps its trailing/not-trailing context. Code + * spans count their full textContent (fences included) and the caret + * may land inside them; synthesized block boundaries count one `\n` + * once the caret is past them. + */ +export function rangeToTextOffset(root: HTMLElement, range: Range | null): number { + if (!range) return serializeContent(root).length; + + // A point is at/before the caret iff it falls inside [root start, caret]. + const pre = range.cloneRange(); + pre.selectNodeContents(root); + pre.setEnd(range.endContainer, range.endOffset); + const atOrBeforeCaret = (node: Node, offset: number) => pre.comparePoint(node, offset) !== 1; + + let total = 0; + let done = false; + // DOM position of a code block's synthesized after-boundary, set + // when walking past a block and consumed by the next contributing + // sibling (counts one `\n` once the caret is past it). + let pendingPoint: { node: Node; index: number } | null = null; + + const walk = (parent: Node) => { + let first = true; + + for (const child of Array.from(parent.childNodes)) { + if (done) return; + + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent ?? ''; + if (text.length === 0) continue; + if (pendingPoint) { + const { node, index } = pendingPoint; + pendingPoint = null; + if (!atOrBeforeCaret(node, index)) { + done = true; + return; + } + total += 1; + } + if (!atOrBeforeCaret(child, 0)) { + done = true; + return; + } + if (range.endContainer === child) { + total += range.endOffset; + done = true; + return; + } + total += text.length; + first = false; + continue; + } + + if (child.nodeType !== Node.ELEMENT_NODE) continue; + + const el = child as HTMLElement; + const parentNode = el.parentNode as Node; + const elIndex = Array.prototype.indexOf.call(parentNode.childNodes, el); + + if (pendingPoint) { + const { node, index } = pendingPoint; + pendingPoint = null; + if (!atOrBeforeCaret(node, index)) { + done = true; + return; + } + total += 1; + } + + if (el.dataset.mentionBadge === 'true') { + const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? ''); + if (len === 0) continue; + if (!atOrBeforeCaret(parentNode, elIndex + 1)) { + done = true; + return; + } + total += len; + first = false; + continue; + } + + if (el.dataset.codeToken !== undefined) { + const isBlock = el.dataset.codeToken === 'block'; + if (isBlock && !first) { + if (!atOrBeforeCaret(el, 0)) { + done = true; + return; + } + total += 1; + } + walk(el); + first = false; + if (isBlock) pendingPoint = { node: parentNode, index: elIndex + 1 }; + continue; + } + + if (el.tagName === 'BR') { + const isHatch = + isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling); + if (isHatch || !el.nextSibling) continue; + if (!atOrBeforeCaret(parentNode, elIndex + 1)) { + done = true; + return; + } + total += 1; + first = false; + continue; + } + + if (BLOCK_TAG_NAMES.has(el.tagName)) { + if (!first) { + if (!atOrBeforeCaret(el, 0)) { + done = true; + return; + } + total += 1; + } + walk(el); + first = false; + continue; + } + + const before = total; + walk(el); + if (total > before) first = false; + } + }; + + walk(root); + return total; +} + +/** + * Materialize a token stream into a DOM subtree: text nodes for text + * tokens, `` elements for badges, + * `` elements for code spans. The badge's class + * string + inline SVG are shared with the rehype plugin via + * `$lib/constants/mention-badge`. + */ +export function buildFragment(tokens: ContentToken[]): DocumentFragment { + const fragment = document.createDocumentFragment(); + + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]; + + if (token.kind === 'text') { + let text = token.text; + + // The separator \n at a fenced-block boundary is synthesized + // at serialization time; keeping it in the DOM would render a + // phantom empty line next to the block. + if (tokens[index - 1]?.kind === 'codeBlock' && text.startsWith('\n')) { + text = text.slice(1); + } + if (tokens[index + 1]?.kind === 'codeBlock' && text.endsWith('\n')) { + text = text.slice(0, -1); + } + if (text.length === 0) continue; + + fragment.appendChild(document.createTextNode(text)); + continue; + } + + if (token.kind === 'inlineCode' || token.kind === 'codeBlock') { + const code = document.createElement('code'); + code.dataset.codeToken = token.kind === 'codeBlock' ? 'block' : 'inline'; + code.textContent = token.text; + fragment.appendChild(code); + continue; + } + + // A leading badge gets an empty text node prepended: without a real + // text position at the buffer start, the spot before the badge is + // unreachable via keyboard (ArrowLeft/Home). + if (!fragment.lastChild) { + fragment.appendChild(document.createTextNode('')); + } + + const badge = document.createElement('span'); + badge.dataset.mentionBadge = 'true'; + badge.dataset.mentionName = token.name; + badge.dataset.mentionPath = token.path; + badge.title = decodeFileLinkPath(token.path); + badge.className = MENTION_BADGE_CLASSNAME; + badge.contentEditable = 'false'; + + const svg = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'svg'); + for (const [attr, value] of Object.entries(MENTION_BADGE_SVG_ATTRIBUTES)) { + svg.setAttribute(attr, value); + } + for (const cls of MENTION_BADGE_ICON_CLASSNAME.split(/\s+/).filter(Boolean)) { + svg.classList.add(cls); + } + + for (const d of getMentionBadgeIconPaths(token.path)) { + const path = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'path'); + path.setAttribute('d', d); + svg.appendChild(path); + } + + const label = document.createElement('span'); + label.classList.add('shrink-0', 'truncate'); + label.textContent = getMentionBadgeLabel( + token.name, + decodeFileLinkPath(token.path), + settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS), + toolsStore.serverHome + ); + + badge.appendChild(svg); + badge.appendChild(label); + fragment.appendChild(badge); + } + + return fragment; +} + +// A sibling provides a reachable caret line when it is an element +// (badge, another block, an existing hatch) or a non-empty text node. +function hasLineBeside(node: Node | null): boolean { + if (!node) return false; + if (node.nodeType === Node.ELEMENT_NODE) return true; + return (node.textContent ?? '') !== ''; +} + +/** + * A code block at the END of the buffer needs an editable line after + * it: without one the caret cannot leave the block with + * ArrowDown/ArrowRight. A trailing `
` provides that line while + * staying transparent to serialization (skipped as a hatch), and is + * removed again once real content takes its place. + * + * No hatch is added BEFORE a leading block: the empty line above it + * is transient and managed by the component (created when the caret + * arrows onto it, removed when the caret leaves). A transient + * leading hatch found here is kept; the browser's lone placeholder + * `
` in an empty root is left untouched. + */ +export function syncCodeBlockHatches(root: HTMLElement) { + for (const child of Array.from(root.childNodes)) { + if (child.nodeName !== 'BR') continue; + + const isPlaceholder = root.childNodes.length === 1; + const isLeadingHatch = !child.previousSibling && isCodeBlockElement(child.nextSibling); + const isTrailingHatch = !child.nextSibling && isCodeBlockElement(child.previousSibling); + + // A hatch goes stale once real content takes over its line: + // content before a leading hatch, content after a trailing one, + // or a text node after the block already providing the line. + // A `
` with no code block around is a real newline (browser + // Shift+Enter shape) and stays. + let prevElement = child.previousSibling; + while (prevElement && prevElement.nodeType !== Node.ELEMENT_NODE) { + prevElement = prevElement.previousSibling; + } + const nearBlock = + isCodeBlockElement(child.nextSibling) || + isCodeBlockElement(child.previousSibling) || + isCodeBlockElement(prevElement); + + if (!isPlaceholder && !isLeadingHatch && !isTrailingHatch && nearBlock) { + child.remove(); + } + } + + for (const child of Array.from(root.childNodes)) { + if (!isCodeBlockElement(child)) continue; + + if (!hasLineBeside(child.nextSibling)) { + child.after(document.createElement('br')); + } + } +} + +/** + * Strip the separator and artificial newlines from an all-newline text + * node directly after a fenced block. Chromium's line break at the + * buffer end inserts an extra artificial `\n` so the new line has + * height, and the first `\n` after a block doubles as the fence's + * separator line (synthesized at serialization time). Removing both + * makes Shift+Enter after a block land the caret on the line directly + * below the block, like a plain textarea would. + * + * Only all-newline text nodes are touched: a node with real content + * carries intentional blank lines and is left alone. Returns true when + * the DOM changed. + */ +export function stripBlockBoundaryLineBreaks(root: HTMLElement): boolean { + let changed = false; + + for (const child of Array.from(root.childNodes)) { + if (child.nodeType !== Node.TEXT_NODE) continue; + if (!isCodeBlockElement(child.previousSibling)) continue; + + let text = child.textContent ?? ''; + if (!/^\n{2,}$/.test(text)) continue; + + text = text.slice(1); + + const atBufferEnd = !child.nextSibling || child.nextSibling.nodeName === 'BR'; + if (atBufferEnd) { + text = text.slice(0, -1); + } + + child.textContent = text; + changed = true; + } + + return changed; +} + +const WORD_CHAR_RE = /[\p{L}\p{N}_]/u; + +/** + * Word-jump target (Option+Arrow / Ctrl+Arrow) in source offsets, or null + * when the jump crosses no badge and native word movement should handle + * it. Badge spans are masked to word characters, so a badge counts as + * exactly one word. + */ +export function badgeAwareWordJump( + source: string, + offset: number, + direction: 'forward' | 'backward' +): number | null { + let masked = ''; + const badgeSpans: Array<[number, number]> = []; + + for (const token of tokenizeContent(source)) { + const len = + token.kind === 'badge' ? badgeSourceLength(token.name, token.path) : token.text.length; + if (token.kind === 'badge') badgeSpans.push([masked.length, masked.length + len]); + masked += token.kind === 'badge' ? 'a'.repeat(len) : token.text; + } + + if (badgeSpans.length === 0) return null; + + const isWord = (index: number) => WORD_CHAR_RE.test(masked[index]); + const spanStartingAt = (index: number) => badgeSpans.find(([start]) => start === index); + const spanEndingAt = (index: number) => badgeSpans.find(([, end]) => end === index); + const n = masked.length; + let i = offset; + + if (direction === 'forward') { + // Entering a badge completes the word phase at the badge's end edge. + if (!(i < n && isWord(i))) { + while (i < n && !isWord(i)) i++; + } + while (i < n && isWord(i)) { + const span = spanStartingAt(i); + if (span) { + i = span[1]; + break; + } + i++; + } + } else { + if (!(i > 0 && isWord(i - 1))) { + while (i > 0 && !isWord(i - 1)) i--; + } + while (i > 0 && isWord(i - 1)) { + const span = spanEndingAt(i); + if (span) { + i = span[0]; + break; + } + i--; + } + } + + if (i === offset) return null; + + const lo = Math.min(offset, i); + const hi = Math.max(offset, i); + return badgeSpans.some(([start, end]) => start < hi && end > lo) ? i : null; +} + +/** + * 0 when `caret` sits exactly at a leading badge's end edge, null + * otherwise. Plain ArrowLeft there has no native previous position, so + * the host snaps the caret to the buffer start manually. + */ +export function leadingBadgeEdgeOffset(source: string, caret: number): number | null { + const [first] = tokenizeContent(source); + if (!first || first.kind !== 'badge') return null; + return caret === badgeSourceLength(first.name, first.path) ? 0 : null; +} + +/** + * Translate a plain-text offset into a degenerate `Range` at that + * position in the DOM; out-of-range offsets clamp to buffer end (before + * a trailing escape hatch, not after it). Zero offset lands BEFORE a + * badge or code span, and an offset exactly at a code span's end lands + * AFTER it, so typing at a code span's edge extends the surrounding + * text. Interior code-span offsets land in the element's text. + * Understands the same block/`
` newline shapes as + * `serializeContent`. + */ +export function textOffsetToRange(root: HTMLElement, offset: number): Range { + const range = document.createRange(); + let remaining = offset; + let landed = false; + let pendingBlockBoundary = false; + + const land = (node: Node, nodeOffset: number) => { + range.setStart(node, nodeOffset); + range.setEnd(node, nodeOffset); + landed = true; + }; + + const walk = (parent: Node) => { + let first = true; + + for (const child of Array.from(parent.childNodes)) { + if (landed) return; + + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent ?? ''; + if (text.length === 0) continue; + if (pendingBlockBoundary) { + // The synthesized separator maps to the near edge of the + // content that follows the block. + pendingBlockBoundary = false; + if (remaining === 0) { + land(child, 0); + return; + } + remaining -= 1; + } + if (remaining <= text.length) { + land(child, remaining); + return; + } + remaining -= text.length; + first = false; + continue; + } + + if (child.nodeType !== Node.ELEMENT_NODE) continue; + + const el = child as HTMLElement; + + if (el.dataset.mentionBadge === 'true') { + const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? ''); + if (len === 0) continue; + if (pendingBlockBoundary) { + pendingBlockBoundary = false; + if (remaining === 0) { + range.setStartBefore(el); + range.setEndBefore(el); + landed = true; + return; + } + remaining -= 1; + } + if (remaining <= len) { + if (remaining === 0) { + range.setStartBefore(el); + range.setEndBefore(el); + } else { + range.setStartAfter(el); + range.setEndAfter(el); + } + landed = true; + return; + } + remaining -= len; + first = false; + continue; + } + + if (el.dataset.codeToken !== undefined) { + const isBlock = el.dataset.codeToken === 'block'; + if (isBlock && (pendingBlockBoundary || !first)) { + pendingBlockBoundary = false; + if (remaining === 0) { + range.setStartBefore(el); + range.setEndBefore(el); + landed = true; + return; + } + remaining -= 1; + } + + const len = (el.textContent ?? '').length; + if (remaining === 0) { + range.setStartBefore(el); + range.setEndBefore(el); + landed = true; + return; + } + if (remaining === len) { + range.setStartAfter(el); + range.setEndAfter(el); + landed = true; + return; + } + if (remaining < len) { + walk(el); + return; + } + remaining -= len; + if (isBlock) remaining -= 1; + first = false; + continue; + } + + if (el.tagName === 'BR') { + const isHatch = + isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling); + if (isHatch) { + // Escape hatch: no source length; offset 0 lands before it + // so text typed there takes its place. + if (remaining === 0) { + range.setStartBefore(el); + range.setEndBefore(el); + landed = true; + } + continue; + } + if (!el.nextSibling) continue; + if (pendingBlockBoundary) { + pendingBlockBoundary = false; + if (remaining === 0) { + range.setStartBefore(el); + range.setEndBefore(el); + landed = true; + return; + } + remaining -= 1; + } + if (remaining === 0) { + range.setStartBefore(el); + range.setEndBefore(el); + landed = true; + return; + } + remaining -= 1; + first = false; + continue; + } + + if (BLOCK_TAG_NAMES.has(el.tagName)) { + if (pendingBlockBoundary || !first) { + pendingBlockBoundary = false; + if (remaining === 0) { + // The boundary newline belongs to the previous line. + range.setStartBefore(el); + range.setEndBefore(el); + landed = true; + return; + } + remaining -= 1; + } + walk(el); + first = false; + continue; + } + + const before = remaining; + walk(el); + if (remaining < before) first = false; + } + }; + + walk(root); + + if (!landed) { + const last = root.lastChild; + if (last && last.nodeName === 'BR') { + range.setStartBefore(last); + range.setEndBefore(last); + } else { + range.selectNodeContents(root); + range.collapse(false); + } + } + + return range; +} diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts index 41428a1c50..6703da373a 100644 --- a/tools/ui/src/lib/utils/index.ts +++ b/tools/ui/src/lib/utils/index.ts @@ -33,6 +33,7 @@ export { export { highlightCode, detectIncompleteCodeBlock, + splitGluedClosingCodeFences, trimCodePadding, type IncompleteCodeBlock } from './code'; @@ -205,9 +206,30 @@ export { type CommandDismissSnapshot } from './command-token'; -// Mention-chip visual contract shared by the rehype file-badge plugin, -// plus the `[name](file://...)` link helpers the mention picker splices in +// Tokenization for the chat-form contenteditable (mention links + code spans <-> chip DOM) export { + tokenizeContent, + containsCodeSpan, + isOffsetInCodeBlock, + domMatchesTokens, + syncCodeBlockHatches, + stripBlockBoundaryLineBreaks, + serializeContent, + buildFragment, + rangeToTextOffset, + textOffsetToRange, + badgeAwareWordJump, + leadingBadgeEdgeOffset, + type ContentToken +} from './contenteditable-tokenizer'; + +// Source-space undo/redo history for the chat-form contenteditable +export { SourceHistory, type SourceHistoryEntry } from './source-history'; + +// Mention-badge visual contract (used by the contenteditable / rehype +// DOM paths that build the same chip without a Svelte mount) +export { + containsFileMentionLink, fileMentionLinkRe, encodeFileLinkPath, decodeFileLinkPath, @@ -218,8 +240,7 @@ export { MENTION_BADGE_FOLDER_ICON_PATHS, getMentionBadgeIconPaths, getMentionBadgeLabel, - buildMentionInsertion, - mentionLinkEndingAt + buildMentionInsertion } from './mention-badge'; // Agentic content utilities (structured section derivation) diff --git a/tools/ui/src/lib/utils/mention-badge.ts b/tools/ui/src/lib/utils/mention-badge.ts index c90f1f98ec..ed0712b46f 100644 --- a/tools/ui/src/lib/utils/mention-badge.ts +++ b/tools/ui/src/lib/utils/mention-badge.ts @@ -23,6 +23,10 @@ export function fileMentionLinkRe(flags = ''): RegExp { return new RegExp(FILE_MENTION_LINK_SOURCE, flags); } +export function containsFileMentionLink(value: string): boolean { + return fileMentionLinkRe().test(value); +} + // Escape each path segment for a markdown link destination (spaces/parens // break CommonMark); keeps the trailing slash that marks a directory. export function encodeFileLinkPath(path: string): string { @@ -60,25 +64,6 @@ export function getMentionBadgeLabel( return abbreviateHome(decoded, home); } -/** - * Extent of the mention link ending exactly at `caret`, so Backspace there - * deletes the whole `[name](file://...)` token in one keystroke instead of - * unraveling it character by character. Null when no link ends at `caret`. - */ -export function mentionLinkEndingAt( - value: string, - caret: number -): { start: number; end: number } | null { - const re = fileMentionLinkRe('g'); - let match: RegExpExecArray | null; - while ((match = re.exec(value)) !== null) { - const end = match.index + match[0].length; - if (end === caret) return { start: match.index, end }; - if (end > caret) break; - } - return null; -} - /** * Build the markdown link that replaces a mention token. Entry `path` is * already rooted, so `file://` + `/abs` yields the canonical `file:///`. diff --git a/tools/ui/src/lib/utils/source-history.ts b/tools/ui/src/lib/utils/source-history.ts new file mode 100644 index 0000000000..3ad2d12987 --- /dev/null +++ b/tools/ui/src/lib/utils/source-history.ts @@ -0,0 +1,48 @@ +/** + * Source-space undo/redo history for the chat-form contenteditable, whose + * imperative DOM rebuilds destroy the browser's native undo stack. + * Entries record the state BEFORE an edit; edits within `groupWindowMs` + * extend the open group so a typing burst undoes as a unit, while + * structural edits (paste, mention insert, clear) pass `newGroup`. + */ + +export interface SourceHistoryEntry { + value: string; + caret: number; +} + +export class SourceHistory { + private undoStack: SourceHistoryEntry[] = []; + private redoStack: SourceHistoryEntry[] = []; + private lastPush = 0; + + constructor( + private limit = 100, + private groupWindowMs = 800 + ) {} + + push(entry: SourceHistoryEntry, now: number, newGroup = false): void { + if (newGroup || now - this.lastPush >= this.groupWindowMs || this.undoStack.length === 0) { + this.undoStack.push(entry); + if (this.undoStack.length > this.limit) this.undoStack.shift(); + } + this.lastPush = now; + this.redoStack = []; + } + + undo(current: SourceHistoryEntry): SourceHistoryEntry | null { + const entry = this.undoStack.pop(); + if (!entry) return null; + this.redoStack.push(current); + this.lastPush = 0; // the next edit after an undo starts a new group + return entry; + } + + redo(current: SourceHistoryEntry): SourceHistoryEntry | null { + const entry = this.redoStack.pop(); + if (!entry) return null; + this.undoStack.push(current); + this.lastPush = 0; + return entry; + } +} diff --git a/tools/ui/tests/client/chat-form-contenteditable-blocks.svelte.test.ts b/tools/ui/tests/client/chat-form-contenteditable-blocks.svelte.test.ts new file mode 100644 index 0000000000..4560a7a9cb --- /dev/null +++ b/tools/ui/tests/client/chat-form-contenteditable-blocks.svelte.test.ts @@ -0,0 +1,156 @@ +// Guards the newline contract of the chat-form contenteditable: browsers +// restructure the flat DOM on Enter (`
` wrappers, `
` shapes) and +// serialization must fold those back into `\n` so the emitted value never +// diverges from what is on screen. + +import { describe, it, expect } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { tick } from 'svelte'; +import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte'; + +const SOURCE = 'see [docs](file:///a/b) here'; + +function editableIn(container: HTMLElement): HTMLElement { + const el = container.querySelector('[role="textbox"]'); + if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered'); + return el; +} + +function fireInput(root: HTMLElement) { + root.dispatchEvent(new InputEvent('input', { bubbles: true })); +} + +function setCaret(node: Node, offset: number) { + const range = document.createRange(); + range.setStart(node, offset); + range.setEnd(node, offset); + const selection = window.getSelection(); + if (!selection) throw new Error('no selection'); + selection.removeAllRanges(); + selection.addRange(range); +} + +describe('ChatFormContenteditable browser newline shapes', () => { + it('serializes a Chromium Enter
wrapper as a newline', async () => { + const screen = render(ChatFormContenteditableHarness, { value: SOURCE }); + await tick(); + + const root = editableIn(screen.container); + const div = document.createElement('div'); + div.textContent = 'second line'; + root.appendChild(div); + fireInput(root); + await tick(); + + expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`); + }); + + it('serializes a Firefox full
wrap as lines, badge included', async () => { + const screen = render(ChatFormContenteditableHarness, { value: SOURCE }); + await tick(); + + const root = editableIn(screen.container); + const first = document.createElement('div'); + while (root.firstChild) first.appendChild(root.firstChild); + const second = document.createElement('div'); + second.textContent = 'second line'; + root.appendChild(first); + root.appendChild(second); + fireInput(root); + await tick(); + + expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`); + }); + + it('serializes a
as a newline', async () => { + const screen = render(ChatFormContenteditableHarness, { value: 'here' }); + await tick(); + + const root = editableIn(screen.container); + root.appendChild(document.createElement('br')); + root.appendChild(document.createTextNode('second line')); + fireInput(root); + await tick(); + + expect(screen.component.getValue()).toBe('here\nsecond line'); + }); + + it('ignores a trailing
(browser caret placeholder)', async () => { + const screen = render(ChatFormContenteditableHarness, { value: 'abc' }); + await tick(); + + const root = editableIn(screen.container); + root.appendChild(document.createElement('br')); + fireInput(root); + await tick(); + + expect(screen.component.getValue()).toBe('abc'); + }); + + it('serializes one newline per empty-line

', async () => { + const screen = render(ChatFormContenteditableHarness, { value: 'abc' }); + await tick(); + + const root = editableIn(screen.container); + for (let i = 0; i < 2; i++) { + const div = document.createElement('div'); + div.appendChild(document.createElement('br')); + root.appendChild(div); + } + fireInput(root); + await tick(); + + expect(screen.component.getValue()).toBe('abc\n\n'); + }); + + it('treats a

-only buffer as empty for the placeholder', async () => { + const screen = render(ChatFormContenteditableHarness, { value: 'abc' }); + await tick(); + + const root = editableIn(screen.container); + const div = document.createElement('div'); + div.appendChild(document.createElement('br')); + root.replaceChildren(div); + fireInput(root); + await tick(); + + expect(screen.component.getValue()).toBe(''); + expect(root.dataset.empty).toBe('true'); + }); + + it('maps the caret across block boundaries in both directions', async () => { + const screen = render(ChatFormContenteditableHarness, { value: 'abc\ndef' }); + await tick(); + + // Rebuild into the Chromium block shape; the source is unchanged, + // so no re-render fires. + const root = editableIn(screen.container); + const div = document.createElement('div'); + div.textContent = 'def'; + root.replaceChildren(document.createTextNode('abc'), div); + fireInput(root); + await tick(); + expect(screen.component.getValue()).toBe('abc\ndef'); + + const divText = div.firstChild; + if (!divText) throw new Error('div text missing'); + + setCaret(divText, 2); + expect(screen.component.getCaretOffset()).toBe(6); + + screen.component.setCaretOffset(6); + const selection = window.getSelection(); + expect(selection?.anchorNode).toBe(divText); + expect(selection?.anchorOffset).toBe(2); + + // The boundary newline itself: offset 3 is the end of "abc", offset + // 4 the start of the "def" line. + screen.component.setCaretOffset(4); + expect(window.getSelection()?.anchorNode).toBe(divText); + expect(window.getSelection()?.anchorOffset).toBe(0); + + screen.component.setCaretOffset(3); + expect(window.getSelection()?.anchorNode).toBe(root.firstChild); + expect(window.getSelection()?.anchorOffset).toBe(3); + }); +}); diff --git a/tools/ui/tests/client/chat-form-contenteditable-undo.svelte.test.ts b/tools/ui/tests/client/chat-form-contenteditable-undo.svelte.test.ts new file mode 100644 index 0000000000..a412bb1668 --- /dev/null +++ b/tools/ui/tests/client/chat-form-contenteditable-undo.svelte.test.ts @@ -0,0 +1,142 @@ +// Guards the editing-key contract of the chat-form contenteditable: +// undo/redo is replayed from source snapshots (the token rebuilds destroy +// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no +// keyboard trap), matching the plain textarea. + +import { describe, it, expect } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { tick } from 'svelte'; +import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte'; + +const SOURCE = 'see [docs](file:///a/b)'; + +function editableIn(container: HTMLElement): HTMLElement { + const el = container.querySelector('[role="textbox"]'); + if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered'); + return el; +} + +function type(root: HTMLElement, text: string, inputType = 'insertText') { + root.appendChild(document.createTextNode(text)); + root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType })); +} + +function keydown(root: HTMLElement, init: KeyboardEventInit) { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }); + root.dispatchEvent(event); + return event; +} + +describe('ChatFormContenteditable undo/redo', () => { + it('undoes and redoes an edit across a badge-containing buffer', async () => { + const screen = render(ChatFormContenteditableHarness, { value: SOURCE }); + await tick(); + + const root = editableIn(screen.container); + type(root, ' more'); + await tick(); + expect(screen.component.getValue()).toBe(`${SOURCE} more`); + + const undoEvent = keydown(root, { key: 'z', ctrlKey: true }); + await tick(); + expect(undoEvent.defaultPrevented).toBe(true); + expect(screen.component.getValue()).toBe(SOURCE); + + const redoEvent = keydown(root, { key: 'z', ctrlKey: true, shiftKey: true }); + await tick(); + expect(redoEvent.defaultPrevented).toBe(true); + expect(screen.component.getValue()).toBe(`${SOURCE} more`); + }); + + it('redoes with Ctrl+Y as well', async () => { + const screen = render(ChatFormContenteditableHarness, { value: SOURCE }); + await tick(); + + const root = editableIn(screen.container); + type(root, ' more'); + await tick(); + keydown(root, { key: 'z', metaKey: true }); + await tick(); + expect(screen.component.getValue()).toBe(SOURCE); + + keydown(root, { key: 'y', ctrlKey: true }); + await tick(); + expect(screen.component.getValue()).toBe(`${SOURCE} more`); + }); + + it('coalesces a typing burst into one undo step', async () => { + const screen = render(ChatFormContenteditableHarness, { value: 'abc' }); + await tick(); + + const root = editableIn(screen.container); + type(root, 'd'); + type(root, 'e'); + await tick(); + expect(screen.component.getValue()).toBe('abcde'); + + keydown(root, { key: 'z', ctrlKey: true }); + await tick(); + expect(screen.component.getValue()).toBe('abc'); + }); + + it('keeps a newline as its own undo step', async () => { + const screen = render(ChatFormContenteditableHarness, { value: 'abc' }); + await tick(); + + const root = editableIn(screen.container); + type(root, 'd'); + type(root, '\n', 'insertLineBreak'); + await tick(); + expect(screen.component.getValue()).toBe('abcd\n'); + + keydown(root, { key: 'z', ctrlKey: true }); + await tick(); + expect(screen.component.getValue()).toBe('abcd'); + + keydown(root, { key: 'z', ctrlKey: true }); + await tick(); + expect(screen.component.getValue()).toBe('abc'); + }); + + it('is a no-op when there is nothing to undo', async () => { + const screen = render(ChatFormContenteditableHarness, { value: 'abc' }); + await tick(); + + const root = editableIn(screen.container); + const event = keydown(root, { key: 'z', ctrlKey: true }); + await tick(); + + expect(event.defaultPrevented).toBe(true); + expect(screen.component.getValue()).toBe('abc'); + }); + + it('abandons the redo branch after a fresh edit', async () => { + const screen = render(ChatFormContenteditableHarness, { value: 'abc' }); + await tick(); + + const root = editableIn(screen.container); + type(root, 'd'); + await tick(); + keydown(root, { key: 'z', ctrlKey: true }); + await tick(); + expect(screen.component.getValue()).toBe('abc'); + + type(root, 'e'); + await tick(); + keydown(root, { key: 'z', ctrlKey: true, shiftKey: true }); + await tick(); + expect(screen.component.getValue()).toBe('abce'); + }); +}); + +describe('ChatFormContenteditable Tab key', () => { + it('does not trap Tab (focus can leave the editable)', async () => { + const screen = render(ChatFormContenteditableHarness, { value: SOURCE }); + await tick(); + + const root = editableIn(screen.container); + const event = keydown(root, { key: 'Tab' }); + + expect(event.defaultPrevented).toBe(false); + }); +}); diff --git a/tools/ui/tests/client/chat-form-contenteditable.svelte.test.ts b/tools/ui/tests/client/chat-form-contenteditable.svelte.test.ts new file mode 100644 index 0000000000..c90f8a83d9 --- /dev/null +++ b/tools/ui/tests/client/chat-form-contenteditable.svelte.test.ts @@ -0,0 +1,701 @@ +// Guards the clipboard contract of the chat-form contenteditable: +// copy/cut expose the markdown SOURCE of the selection (each badge +// contributes its full `[name](file://...)` link) and pasting such +// markdown re-renders the badges. + +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { userEvent } from 'vitest/browser'; +import { tick } from 'svelte'; +import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils'; +import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte'; + +const SOURCE = 'hello [docs](file:///a/b) world'; +const BADGE_SELECTOR = '[data-mention-badge="true"]'; + +function editableIn(container: HTMLElement): HTMLElement { + const el = container.querySelector('[role="textbox"]'); + if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered'); + return el; +} + +function setSelection(root: HTMLElement, place: (range: Range, root: HTMLElement) => void) { + const range = document.createRange(); + place(range, root); + const selection = window.getSelection(); + if (!selection) throw new Error('no selection'); + selection.removeAllRanges(); + selection.addRange(range); +} + +function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') { + const data = new DataTransfer(); + if (text) data.setData('text/plain', text); + const event = new ClipboardEvent(type, { clipboardData: data, bubbles: true, cancelable: true }); + return { event, data }; +} + +describe('ChatFormContenteditable clipboard', () => { + it('copy exposes the markdown source of the selection', async () => { + const { container } = render(ChatFormContenteditable, { value: SOURCE }); + await tick(); + + const root = editableIn(container); + setSelection(root, (range) => range.selectNodeContents(root)); + + const { event, data } = clipboardEvent('copy'); + root.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(data.getData('text/plain')).toBe(SOURCE); + }); + + it('cut exposes the markdown source and removes the slice', async () => { + const { container } = render(ChatFormContenteditable, { value: SOURCE }); + await tick(); + + const root = editableIn(container); + setSelection(root, (range) => { + const badge = root.querySelector(BADGE_SELECTOR); + if (!badge) throw new Error('badge not rendered'); + range.setStartBefore(badge); + range.setEndAfter(badge); + }); + + const { event, data } = clipboardEvent('cut'); + root.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(data.getData('text/plain')).toBe('[docs](file:///a/b)'); + expect(root.querySelector(BADGE_SELECTOR)).toBeNull(); + expect(root.textContent).toBe('hello world'); + }); + + it('paste of markdown mention links re-renders badges', async () => { + const { container } = render(ChatFormContenteditable, { value: 'hello ' }); + await tick(); + + const root = editableIn(container); + root.focus(); + setSelection(root, (range) => { + range.selectNodeContents(root); + range.collapse(false); + }); + + const { event } = clipboardEvent('paste', '[docs](file:///a/b) world'); + root.dispatchEvent(event); + await tick(); + + expect(event.defaultPrevented).toBe(true); + const badge = root.querySelector(BADGE_SELECTOR); + expect(badge).not.toBeNull(); + expect(badge!.getAttribute('data-mention-name')).toBe('docs'); + expect(root.textContent).toContain('world'); + }); + + it('paste without mention links keeps the DOM untouched', async () => { + const { container } = render(ChatFormContenteditable, { value: 'hello ' }); + await tick(); + + const root = editableIn(container); + root.focus(); + setSelection(root, (range) => { + range.selectNodeContents(root); + range.collapse(false); + }); + const firstChild = root.firstChild; + + const { event } = clipboardEvent('paste', 'plain text'); + root.dispatchEvent(event); + await tick(); + + expect(event.defaultPrevented).toBe(true); + expect(root.querySelector(BADGE_SELECTOR)).toBeNull(); + // no rebuild: the live text node is the same instance + expect(root.firstChild).toBe(firstChild); + }); +}); + +describe('ChatFormContenteditable code spans', () => { + it('renders inline code from the initial value', async () => { + const { container } = render(ChatFormContenteditable, { value: 'run `npm test` now' }); + await tick(); + + const root = editableIn(container); + const code = root.querySelector('code[data-code-token="inline"]'); + expect(code).not.toBeNull(); + expect(code!.textContent).toBe('`npm test`'); + }); + + it('renders a fenced code block with a language', async () => { + const source = 'before\n```js\nconst a = 1;\n```\nafter'; + const { container } = render(ChatFormContenteditable, { value: source }); + await tick(); + + const root = editableIn(container); + const code = root.querySelector('code[data-code-token="block"]'); + expect(code).not.toBeNull(); + expect(code!.textContent).toBe('```js\nconst a = 1;\n```'); + }); + + it('copy exposes the markdown source of a selection spanning code', async () => { + const source = 'run `npm test` now'; + const { container } = render(ChatFormContenteditable, { value: source }); + await tick(); + + const root = editableIn(container); + setSelection(root, (range) => range.selectNodeContents(root)); + + const { event, data } = clipboardEvent('copy'); + root.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(data.getData('text/plain')).toBe(source); + }); + + it('paste of a code span renders the styled element', async () => { + const { container } = render(ChatFormContenteditable, { value: 'run ' }); + await tick(); + + const root = editableIn(container); + root.focus(); + setSelection(root, (range) => { + range.selectNodeContents(root); + range.collapse(false); + }); + + const { event } = clipboardEvent('paste', '`npm test` now'); + root.dispatchEvent(event); + await tick(); + + expect(event.defaultPrevented).toBe(true); + const code = root.querySelector('code[data-code-token="inline"]'); + expect(code).not.toBeNull(); + expect(code!.textContent).toBe('`npm test`'); + expect(root.textContent).toContain('now'); + }); + + it('highlights a fenced block content and stays byte-exact', async () => { + const source = '```js\nconst a = 1;\n```'; + const { container } = render(ChatFormContenteditable, { value: source }); + await tick(); + + const root = editableIn(container); + const code = root.querySelector('code[data-code-token="block"]'); + expect(code).not.toBeNull(); + expect(code!.querySelector('.hljs-keyword')).not.toBeNull(); + expect(code!.textContent).toBe(source); + }); + + it('does not highlight inline code', async () => { + const { container } = render(ChatFormContenteditable, { value: 'run `const` now' }); + await tick(); + + const root = editableIn(container); + expect(root.querySelector('[class*="hljs-"]')).toBeNull(); + }); +}); + +describe('ChatFormContenteditable code block escape hatches', () => { + const BLOCK_SOURCE = '```js\nconst a = 1;\n```'; + const BLOCK_SELECTOR = 'code[data-code-token="block"]'; + + function blockIn(root: HTMLElement): HTMLElement { + const el = root.querySelector(BLOCK_SELECTOR); + if (!(el instanceof HTMLElement)) throw new Error('code block not rendered'); + return el; + } + + // Caret at the very start/end of the block's text (across highlight spans) + function placeCaretInBlock(root: HTMLElement, where: 'start' | 'end') { + const code = blockIn(root); + const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT); + let target: Node | null = null; + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + target = where === 'start' ? (target ?? n) : n; + } + if (!target) throw new Error('no text inside code block'); + setSelection(root, (range) => { + range.setStart(target!, where === 'start' ? 0 : (target!.textContent ?? '').length); + range.collapse(true); + }); + } + + function caretContainer(): Node { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) throw new Error('no selection'); + return selection.getRangeAt(0).startContainer; + } + + it('pads a trailing code block with a br hatch that stays invisible to copy', async () => { + const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE }); + await tick(); + + const root = editableIn(container); + // no permanent empty line above a leading block + expect(root.firstChild).toBe(blockIn(root)); + expect(root.lastChild?.nodeName).toBe('BR'); + + setSelection(root, (range) => range.selectNodeContents(root)); + const { event, data } = clipboardEvent('copy'); + root.dispatchEvent(event); + + expect(data.getData('text/plain')).toBe(BLOCK_SOURCE); + }); + + it('escapes a trailing code block with ArrowDown and types after it', async () => { + const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE }); + await tick(); + + const root = editableIn(container); + root.focus(); + placeCaretInBlock(root, 'end'); + + await userEvent.keyboard('{ArrowDown}'); + expect(blockIn(root).contains(caretContainer())).toBe(false); + + await userEvent.keyboard('x'); + await tick(); + + expect(blockIn(root).textContent).toBe(BLOCK_SOURCE); + // the DOM holds no separator newline (it would render as a + // phantom empty line); serialization synthesizes it so the + // markdown source keeps the text below the block + expect(root.textContent).toBe(BLOCK_SOURCE + 'x'); + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx'); + // the stale trailing hatch is removed once real text follows the block + expect(root.lastChild?.nodeName).not.toBe('BR'); + }); + + it('escapes a leading code block with ArrowUp and types before it', async () => { + const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE }); + await tick(); + + const root = editableIn(container); + root.focus(); + placeCaretInBlock(root, 'start'); + + await userEvent.keyboard('{ArrowUp}'); + expect(blockIn(root).contains(caretContainer())).toBe(false); + // the transient hatch line exists while the caret sits on it + expect(root.firstChild?.nodeName).toBe('BR'); + + await userEvent.keyboard('y'); + await tick(); + + expect(blockIn(root).textContent).toBe(BLOCK_SOURCE); + expect(root.textContent).toBe('y' + BLOCK_SOURCE); + expect(serializeContent(root)).toBe('y\n' + BLOCK_SOURCE); + // the typed text consumed the hatch + expect(root.firstChild?.nodeName).not.toBe('BR'); + }); + + it('escapes a leading code block with ArrowLeft from its first character', async () => { + const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE }); + await tick(); + + const root = editableIn(container); + root.focus(); + placeCaretInBlock(root, 'start'); + + await userEvent.keyboard('{ArrowLeft}'); + expect(blockIn(root).contains(caretContainer())).toBe(false); + expect(root.firstChild?.nodeName).toBe('BR'); + }); + + it('removes the transient leading hatch when the caret moves back into the block', async () => { + const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE }); + await tick(); + + const root = editableIn(container); + root.focus(); + placeCaretInBlock(root, 'start'); + + await userEvent.keyboard('{ArrowUp}'); + expect(root.firstChild?.nodeName).toBe('BR'); + + await userEvent.keyboard('{ArrowDown}'); + await tick(); + + expect(blockIn(root).contains(caretContainer())).toBe(true); + expect(root.firstChild).toBe(blockIn(root)); + }); + + it('extends the selection out of the block with Shift+ArrowDown', async () => { + const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE }); + await tick(); + + const root = editableIn(container); + root.focus(); + placeCaretInBlock(root, 'end'); + + await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}'); + + const selection = window.getSelection(); + expect(selection).not.toBeNull(); + expect(selection!.isCollapsed).toBe(false); + expect(blockIn(root).contains(selection!.getRangeAt(0).endContainer)).toBe(false); + }); + + it('line-separates text typed right after the closing fence', async () => { + const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE }); + await tick(); + + const root = editableIn(container); + root.focus(); + placeCaretInBlock(root, 'end'); + + // no arrow keys: the caret sits at the block's end edge, where the + // post-rebuild restore lands it, and the typed text renders on the + // line below the block + await userEvent.keyboard('x'); + await tick(); + + // the text stays on the caret's line in the DOM (no phantom empty + // line); the source gets the separator newline + expect(root.textContent).toBe(BLOCK_SOURCE + 'x'); + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx'); + }); + + it('does not double the newline when Shift+Enter already added one', async () => { + const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE }); + await tick(); + + const root = editableIn(container); + root.focus(); + placeCaretInBlock(root, 'end'); + + await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); + await userEvent.keyboard('x'); + await tick(); + + expect(root.textContent).toBe(BLOCK_SOURCE + 'x'); + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx'); + }); + + it('moves a caret stuck before the inserted newline onto the new line', async () => { + const { container } = render(ChatFormContenteditable, { + value: BLOCK_SOURCE + '\ntext after the code block' + }); + await tick(); + + const root = editableIn(container); + root.focus(); + + // post-break DOM some browsers produce: the inserted newline plus + // the artificial trailing one, with the caret stuck BEFORE the + // inserted one (visually at the end of the old line) + root.appendChild(document.createTextNode('\n')); + root.appendChild(document.createTextNode('\n')); + setSelection(root, (range) => { + range.setStart(root.childNodes[2], 0); + range.collapse(true); + }); + + root.dispatchEvent(new InputEvent('input', { inputType: 'insertLineBreak', bubbles: true })); + await tick(); + + const selection = window.getSelection(); + expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe( + (BLOCK_SOURCE + '\ntext after the code block\n').length + ); + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n'); + }); + + it('appends the artificial trailing newline when the browser did not add one', async () => { + const { container } = render(ChatFormContenteditable, { + value: BLOCK_SOURCE + '\ntext after the code block' + }); + await tick(); + + const root = editableIn(container); + root.focus(); + + // post-break DOM some browsers produce: a lone trailing \n (or a + //
the hatch sync strips). Collapsed by the renderer, so the + // caret looks stuck on the old line and the next typed character + // would consume the newline. + root.appendChild(document.createTextNode('\n')); + setSelection(root, (range) => { + range.setStart(root.childNodes[2], 1); + range.collapse(true); + }); + + root.dispatchEvent(new InputEvent('input', { inputType: 'insertLineBreak', bubbles: true })); + await tick(); + + const selection = window.getSelection(); + expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe( + (BLOCK_SOURCE + '\ntext after the code block\n').length + ); + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n'); + }); + + it('lands the caret on the new line with a single Shift+Enter after text below a block', async () => { + const { container } = render(ChatFormContenteditable, { + value: BLOCK_SOURCE + '\ntext after the code block' + }); + await tick(); + + const root = editableIn(container); + root.focus(); + setSelection(root, (range) => { + const text = root.childNodes[1]; + range.setStart(text, (text.textContent ?? '').length); + range.collapse(true); + }); + + await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); + await tick(); + + const selection = window.getSelection(); + expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe( + (BLOCK_SOURCE + '\ntext after the code block\n').length + ); + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n'); + + // the next typed character lands on the new line + await userEvent.keyboard('x'); + await tick(); + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\nx'); + }); + + it('lets Backspace at the text start move into the block without a source fight', async () => { + const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE }); + await tick(); + + const root = editableIn(container); + root.focus(); + placeCaretInBlock(root, 'end'); + + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('create'); + await tick(); + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate'); + + // Backspace at the start of the text line: the separator newline + // is structural (synthesized while text follows the block), so + // the caret just moves to the block's edge - nothing is re-added + await userEvent.keyboard('{Home}'); + await userEvent.keyboard('{Backspace}'); + await tick(); + + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate'); + expect(caretContainer()).toBe(root); + }); + + it('lets forward Delete eat the text after a block normally', async () => { + const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE }); + await tick(); + + const root = editableIn(container); + root.focus(); + placeCaretInBlock(root, 'end'); + + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('create'); + await tick(); + + await userEvent.keyboard('{Home}'); + await userEvent.keyboard('{Delete}'); + await tick(); + + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nreate'); + }); + + it('renders text after a block without a phantom empty line', async () => { + const { container } = render(ChatFormContenteditable, { + value: BLOCK_SOURCE + '\nhello' + }); + await tick(); + + const root = editableIn(container); + expect(root.textContent).toBe(BLOCK_SOURCE + 'hello'); + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nhello'); + }); + + it('keeps an intentional blank line after a block out of the separator', async () => { + const { container } = render(ChatFormContenteditable, { + value: BLOCK_SOURCE + '\n\nhello' + }); + await tick(); + + const root = editableIn(container); + expect(root.textContent).toBe(BLOCK_SOURCE + '\nhello'); + expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\n\nhello'); + }); + + it('re-highlights while typing inside a block and keeps the caret', async () => { + const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE }); + await tick(); + + const root = editableIn(container); + root.focus(); + + // caret at the start of the block content (after the opening fence) + setSelection(root, (range) => { + const target = textOffsetToRange(root, 6); + range.setStart(target.startContainer, target.startOffset); + range.collapse(true); + }); + + await userEvent.keyboard('x'); + await tick(); + + const code = blockIn(root); + expect(serializeContent(root)).toBe('```js\nxconst a = 1;\n```'); + expect(code.textContent).toBe('```js\nxconst a = 1;\n```'); + expect(code.querySelector('.hljs-number')).not.toBeNull(); + + const selection = window.getSelection(); + expect(code.contains(selection!.getRangeAt(0).startContainer)).toBe(true); + expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7); + }); +}); + +describe('ChatFormContenteditable Enter in code blocks', () => { + const BLOCK_SOURCE = '```js\nconst a = 1;\n```'; + + it('adds a line instead of submitting on plain Enter inside a block', async () => { + const onKeydown = vi.fn(); + const { container } = render(ChatFormContenteditable, { + value: BLOCK_SOURCE, + onKeydown + }); + await tick(); + + const root = editableIn(container); + root.focus(); + + // caret at the start of the block content (after the opening fence) + setSelection(root, (range) => { + const target = textOffsetToRange(root, 6); + range.setStart(target.startContainer, target.startOffset); + range.collapse(true); + }); + + await userEvent.keyboard('{Enter}'); + await tick(); + + // consumed locally: the parent's submit handler never sees it + expect(onKeydown).not.toHaveBeenCalled(); + expect(serializeContent(root)).toBe('```js\n\nconst a = 1;\n```'); + + const code = root.querySelector('code[data-code-token="block"]'); + const selection = window.getSelection(); + expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true); + expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7); + }); + + it('adds a line after a still-open fence (no closing ``` yet)', async () => { + const onKeydown = vi.fn(); + const { container } = render(ChatFormContenteditable, { + value: '```js\nconst a = 1;', + onKeydown + }); + await tick(); + + const root = editableIn(container); + root.focus(); + + // caret at the start of the block content (after the opening fence) + setSelection(root, (range) => { + const target = textOffsetToRange(root, 6); + range.setStart(target.startContainer, target.startOffset); + range.collapse(true); + }); + + await userEvent.keyboard('{Enter}'); + await tick(); + + expect(onKeydown).not.toHaveBeenCalled(); + expect(serializeContent(root)).toBe('```js\n\nconst a = 1;'); + }); + + it('forwards plain Enter to the parent when the caret is outside a block', async () => { + const onKeydown = vi.fn(); + const { container } = render(ChatFormContenteditable, { + value: BLOCK_SOURCE + '\nafter', + onKeydown + }); + await tick(); + + const root = editableIn(container); + root.focus(); + setSelection(root, (range) => { + range.selectNodeContents(root); + range.collapse(false); + }); + + await userEvent.keyboard('{Enter}'); + + expect(onKeydown).toHaveBeenCalledTimes(1); + expect(onKeydown.mock.calls[0][0].defaultPrevented).toBe(false); + }); + + it('forwards plain Enter on the trailing hatch line after a block', async () => { + const onKeydown = vi.fn(); + const { container } = render(ChatFormContenteditable, { + value: BLOCK_SOURCE, + onKeydown + }); + await tick(); + + const root = editableIn(container); + root.focus(); + // root-level caret between the block and its trailing br hatch + setSelection(root, (range) => { + range.setStart(root, 1); + range.collapse(true); + }); + + await userEvent.keyboard('{Enter}'); + + expect(onKeydown).toHaveBeenCalledTimes(1); + }); + + it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => { + const onKeydown = vi.fn(); + const { container } = render(ChatFormContenteditable, { + value: BLOCK_SOURCE, + onKeydown + }); + await tick(); + + const root = editableIn(container); + root.focus(); + setSelection(root, (range) => { + const target = textOffsetToRange(root, 6); + range.setStart(target.startContainer, target.startOffset); + range.collapse(true); + }); + + await userEvent.keyboard('{Control>}{Enter}{/Control}'); + + expect(onKeydown).toHaveBeenCalledWith( + expect.objectContaining({ key: 'Enter', ctrlKey: true }) + ); + expect(serializeContent(root)).toBe(BLOCK_SOURCE); + }); + + it('forwards Enter inside an inline code span', async () => { + const onKeydown = vi.fn(); + const { container } = render(ChatFormContenteditable, { + value: 'run `npm test` now', + onKeydown + }); + await tick(); + + const root = editableIn(container); + root.focus(); + const code = root.querySelector('code[data-code-token="inline"]')!; + setSelection(root, (range) => { + range.setStart(code.firstChild!, 3); + range.collapse(true); + }); + + await userEvent.keyboard('{Enter}'); + + expect(onKeydown).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts b/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts new file mode 100644 index 0000000000..c5d2f126ca --- /dev/null +++ b/tools/ui/tests/client/chat-form-enter-code-block.svelte.test.ts @@ -0,0 +1,112 @@ +// Guards the Enter-key contract of the chat form against the +// fenced-code-block flow: while the caret sits inside a fenced +// block region - closed, or still OPEN while the user is typing +// one - plain Enter adds a line instead of submitting the message. +// The textarea path is covered here end-to-end (the contenteditable +// consumes the same case locally; see chat-form-contenteditable). + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { userEvent } from 'vitest/browser'; +import { tick } from 'svelte'; +import { SETTINGS_KEYS } from '$lib/constants/settings-keys'; +import { settingsStore } from '$lib/stores/settings.svelte'; +import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte'; + +function textareaIn(container: HTMLElement): HTMLTextAreaElement { + const el = container.querySelector('textarea'); + if (!(el instanceof HTMLTextAreaElement)) throw new Error('textarea not rendered'); + return el; +} + +describe('ChatForm Enter in code blocks', () => { + beforeEach(() => { + settingsStore.updateConfig(SETTINGS_KEYS.SEND_ON_ENTER, true); + }); + + it('adds a line after a still-open fence instead of submitting', async () => { + const onSubmit = vi.fn(); + const { container } = render(ChatFormTestWrapper, { onSubmit }); + await tick(); + + const textarea = textareaIn(container); + await userEvent.click(textarea); + await userEvent.keyboard('```'); + await tick(); + + await userEvent.keyboard('{Enter}'); + await tick(); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(textarea.value).toBe('```\n'); + }); + + it('keeps adding lines while the block stays open', async () => { + const onSubmit = vi.fn(); + const { container } = render(ChatFormTestWrapper, { onSubmit }); + await tick(); + + const textarea = textareaIn(container); + await userEvent.click(textarea); + await userEvent.keyboard('```js'); + await tick(); + + await userEvent.keyboard('{Enter}'); + await userEvent.keyboard('const a = 1;'); + await userEvent.keyboard('{Enter}'); + await tick(); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(textarea.value).toBe('```js\nconst a = 1;\n'); + }); + + it('submits when the caret is before the opening fence', async () => { + const onSubmit = vi.fn(); + const { container } = render(ChatFormTestWrapper, { onSubmit }); + await tick(); + + const textarea = textareaIn(container); + await userEvent.click(textarea); + await userEvent.keyboard('```'); + await tick(); + + textarea.setSelectionRange(0, 0); + + await userEvent.keyboard('{Enter}'); + await tick(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('submits on Enter outside a code block', async () => { + const onSubmit = vi.fn(); + const { container } = render(ChatFormTestWrapper, { onSubmit }); + await tick(); + + const textarea = textareaIn(container); + await userEvent.click(textarea); + await userEvent.keyboard('hello'); + await tick(); + + await userEvent.keyboard('{Enter}'); + await tick(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('submits on Ctrl+Enter even inside a code block', async () => { + const onSubmit = vi.fn(); + const { container } = render(ChatFormTestWrapper, { onSubmit }); + await tick(); + + const textarea = textareaIn(container); + await userEvent.click(textarea); + await userEvent.keyboard('```'); + await tick(); + + await userEvent.keyboard('{Control>}{Enter}{/Control}'); + await tick(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tools/ui/tests/client/components/ChatFormContenteditableHarness.svelte b/tools/ui/tests/client/components/ChatFormContenteditableHarness.svelte new file mode 100644 index 0000000000..bfc567e62b --- /dev/null +++ b/tools/ui/tests/client/components/ChatFormContenteditableHarness.svelte @@ -0,0 +1,27 @@ + + + diff --git a/tools/ui/tests/client/components/ChatFormTestWrapper.svelte b/tools/ui/tests/client/components/ChatFormTestWrapper.svelte new file mode 100644 index 0000000000..78a431157c --- /dev/null +++ b/tools/ui/tests/client/components/ChatFormTestWrapper.svelte @@ -0,0 +1,12 @@ + + + + + diff --git a/tools/ui/tests/unit/code.test.ts b/tools/ui/tests/unit/code.test.ts index 7820c98072..99fad60446 100644 --- a/tools/ui/tests/unit/code.test.ts +++ b/tools/ui/tests/unit/code.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { highlightCode, trimCodePadding } from '$lib/utils/code'; +import { highlightCode, splitGluedClosingCodeFences, trimCodePadding } from '$lib/utils/code'; describe('trimCodePadding', () => { it('removes a single leading newline', () => { @@ -101,3 +101,38 @@ describe('highlightCode', () => { expect(html).toBe('<script>a && b</script>'); }); }); + +describe('splitGluedClosingCodeFences', () => { + it('splits text glued to a closing fence onto its own line', () => { + const input = "```ts\nlet foo = 'bar';\n```create this file on [Desktop](file:///a/b/)"; + expect(splitGluedClosingCodeFences(input)).toBe( + "```ts\nlet foo = 'bar';\n```\ncreate this file on [Desktop](file:///a/b/)" + ); + }); + + it('leaves a well-formed code block untouched', () => { + const input = "```ts\nlet foo = 'bar';\n```\ncreate this file on [Desktop](file:///a/b/)"; + expect(splitGluedClosingCodeFences(input)).toBe(input); + }); + + it('leaves content without fences untouched', () => { + expect(splitGluedClosingCodeFences('hello world')).toBe('hello world'); + }); + + it('keeps nested markdown fences inside a block intact', () => { + const input = '```md\n# Example\n```python\nprint(1)\n```\n```'; + expect(splitGluedClosingCodeFences(input)).toBe(input); + }); + + it('splits every glued closing fence when several blocks are present', () => { + const input = '```ts\na\n```first words\n\n```js\nb\n```second words'; + expect(splitGluedClosingCodeFences(input)).toBe( + '```ts\na\n```\nfirst words\n\n```js\nb\n```\nsecond words' + ); + }); + + it('leaves a still-open fence untouched', () => { + const input = '```ts\nlet foo = 1;'; + expect(splitGluedClosingCodeFences(input)).toBe(input); + }); +}); diff --git a/tools/ui/tests/unit/contenteditable-tokenizer.test.ts b/tools/ui/tests/unit/contenteditable-tokenizer.test.ts new file mode 100644 index 0000000000..8752415f02 --- /dev/null +++ b/tools/ui/tests/unit/contenteditable-tokenizer.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest'; +import { containsCodeSpan, isOffsetInCodeBlock, tokenizeContent } from '$lib/utils'; + +describe('tokenizeContent', () => { + it('tokenizes a plain text buffer with no badges', () => { + expect(tokenizeContent('hello world')).toEqual([{ kind: 'text', text: 'hello world' }]); + }); + + it('tokenizes a single badge', () => { + expect(tokenizeContent('[docs](file:///a/b)')).toEqual([ + { kind: 'badge', name: 'docs', path: '/a/b' } + ]); + }); + + it('tokenizes text around a single badge', () => { + expect(tokenizeContent('hello [docs](file:///a/b) world')).toEqual([ + { kind: 'text', text: 'hello ' }, + { kind: 'badge', name: 'docs', path: '/a/b' }, + { kind: 'text', text: ' world' } + ]); + }); + + it('tokenizes adjacent badges as separate tokens', () => { + expect(tokenizeContent('[a](file:///x)[b](file:///y)')).toEqual([ + { kind: 'badge', name: 'a', path: '/x' }, + { kind: 'badge', name: 'b', path: '/y' } + ]); + }); + + it('leaves non-file links untouched in the stream', () => { + expect(tokenizeContent('see [foo](https://example.com) for details')).toEqual([ + { kind: 'text', text: 'see [foo](https://example.com) for details' } + ]); + }); + + it('recognizes badges whose path contains spaces (macOS screenshots)', () => { + const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png'; + const source = `[Screenshot 2026-07-28 at 17.21.50.png](file://${path}) `; + expect(tokenizeContent(source)).toEqual([ + { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path }, + { kind: 'text', text: ' ' } + ]); + }); + + it('recognizes badges whose path lives in the macOS temp folder', () => { + const path = + '/var/folders/78/j28m7pn57wb34bfjwlskh62h0000gn/T/TemporaryItems/NSIRD_screencaptureui_GD0A2R/Screenshot 2026-07-28 at 17.23.28.png'; + const source = `[Screenshot 2026-07-28 at 17.23.28.png](file://${path}) `; + expect(tokenizeContent(source)).toEqual([ + { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.23.28.png', path }, + { kind: 'text', text: ' ' } + ]); + }); + + it('keeps text around a badge with spaces in the path', () => { + const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png'; + const source = `see [Screenshot 2026-07-28 at 17.21.50.png](file://${path}) done`; + expect(tokenizeContent(source)).toEqual([ + { kind: 'text', text: 'see ' }, + { kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path }, + { kind: 'text', text: ' done' } + ]); + }); + + it('recognizes badges whose path contains a close parenthesis (macOS duplicate files)', () => { + const path = '/Users/foo/Screenshot (1).png'; + const source = `[Screenshot (1).png](file://${path}) `; + expect(tokenizeContent(source)).toEqual([ + { kind: 'badge', name: 'Screenshot (1).png', path }, + { kind: 'text', text: ' ' } + ]); + }); + + it('recognizes badges whose folder name is wrapped in parentheses', () => { + const path = '/Users/foo/Project (Stuff)/main.rs'; + const source = `[main.rs](file://${path}) `; + expect(tokenizeContent(source)).toEqual([ + { kind: 'badge', name: 'main.rs', path }, + { kind: 'text', text: ' ' } + ]); + }); + + it('recognizes adjacent badges back-to-back with no separator', () => { + const source = '[a](file:///p)[b](file:///q)'; + expect(tokenizeContent(source)).toEqual([ + { kind: 'badge', name: 'a', path: '/p' }, + { kind: 'badge', name: 'b', path: '/q' } + ]); + }); + + it('tokenizes inline code with the backticks included', () => { + expect(tokenizeContent('run `npm test` now')).toEqual([ + { kind: 'text', text: 'run ' }, + { kind: 'inlineCode', text: '`npm test`' }, + { kind: 'text', text: ' now' } + ]); + }); + + it('tokenizes a fenced code block without a language', () => { + const source = 'before\n```\nconst a = 1;\n```\nafter'; + expect(tokenizeContent(source)).toEqual([ + { kind: 'text', text: 'before\n' }, + { kind: 'codeBlock', text: '```\nconst a = 1;\n```' }, + { kind: 'text', text: '\nafter' } + ]); + }); + + it('tokenizes a fenced code block with a language', () => { + const source = '```js\nconst a = 1;\n```'; + expect(tokenizeContent(source)).toEqual([ + { kind: 'codeBlock', text: '```js\nconst a = 1;\n```' } + ]); + }); + + it('prefers the fenced block over inline spans at triple backticks', () => { + expect(tokenizeContent('```a``` ```b```')).toEqual([ + { kind: 'codeBlock', text: '```a```' }, + { kind: 'text', text: ' ' }, + { kind: 'codeBlock', text: '```b```' } + ]); + }); + + it('leaves an unclosed fence as plain text', () => { + expect(tokenizeContent('```js\nconst a = 1;')).toEqual([ + { kind: 'text', text: '```js\nconst a = 1;' } + ]); + }); + + it('leaves an unclosed inline backtick as plain text', () => { + expect(tokenizeContent('run `npm test')).toEqual([{ kind: 'text', text: 'run `npm test' }]); + }); + + it('does not recognize badges inside code spans', () => { + expect(tokenizeContent('`[a](file:///p)`')).toEqual([ + { kind: 'inlineCode', text: '`[a](file:///p)`' } + ]); + }); + + it('tokenizes badges and code spans side by side', () => { + expect(tokenizeContent('[a](file:///p) `x`')).toEqual([ + { kind: 'badge', name: 'a', path: '/p' }, + { kind: 'text', text: ' ' }, + { kind: 'inlineCode', text: '`x`' } + ]); + }); +}); + +describe('containsCodeSpan', () => { + it('detects inline code', () => { + expect(containsCodeSpan('run `npm test` now')).toBe(true); + }); + + it('detects a fenced block with a language', () => { + expect(containsCodeSpan('```js\nconst a = 1;\n```')).toBe(true); + }); + + it('detects a fenced block without a language', () => { + expect(containsCodeSpan('```\ncode\n```')).toBe(true); + }); + + it('ignores unclosed fences and lone backticks', () => { + expect(containsCodeSpan('```js\nconst a = 1;')).toBe(false); + expect(containsCodeSpan('run `npm test')).toBe(false); + expect(containsCodeSpan('``')).toBe(false); + }); + + it('ignores plain text and mention links', () => { + expect(containsCodeSpan('hello world')).toBe(false); + expect(containsCodeSpan('[a](file:///p)')).toBe(false); + }); +}); + +describe('isOffsetInCodeBlock', () => { + const BLOCK = '```js\nconst a = 1;\n```'; + + it('is false with no fences in the buffer', () => { + expect(isOffsetInCodeBlock('hello world', 5)).toBe(false); + expect(isOffsetInCodeBlock('run `npm test` now', 10)).toBe(false); + }); + + it('is true right after the opening fence, before any content', () => { + expect(isOffsetInCodeBlock('```', 3)).toBe(true); + expect(isOffsetInCodeBlock('```js', 5)).toBe(true); + }); + + it('is true inside a still-open block while it is being typed', () => { + const open = '```js\nconst a = 1;'; + expect(isOffsetInCodeBlock(open, open.length)).toBe(true); + }); + + it('is true inside a closed block and false outside it', () => { + expect(isOffsetInCodeBlock(BLOCK, 6)).toBe(true); + expect(isOffsetInCodeBlock(BLOCK, 0)).toBe(false); + expect(isOffsetInCodeBlock(BLOCK, BLOCK.length)).toBe(false); + expect(isOffsetInCodeBlock(BLOCK + '\nafter', BLOCK.length + 5)).toBe(false); + }); + + it('toggles per fence across multiple blocks', () => { + const two = BLOCK + '\ntext\n' + BLOCK; + const secondBlock = two.lastIndexOf(BLOCK); + expect(isOffsetInCodeBlock(two, secondBlock - 2)).toBe(false); + expect(isOffsetInCodeBlock(two, secondBlock + 6)).toBe(true); + expect(isOffsetInCodeBlock(two, two.length)).toBe(false); + }); +}); diff --git a/tools/ui/tests/unit/contenteditable-word-jump.test.ts b/tools/ui/tests/unit/contenteditable-word-jump.test.ts new file mode 100644 index 0000000000..8e68950d48 --- /dev/null +++ b/tools/ui/tests/unit/contenteditable-word-jump.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { badgeAwareWordJump, leadingBadgeEdgeOffset } from '$lib/utils'; + +// Layout of `hello [docs](file:///a/b) world foo`: +// "hello" 0-4, " " 5, badge 6-24 (length 19), " " 25, "world" 26-30, " " 31, "foo" 32-34 +const BADGE = '[docs](file:///a/b)'; +const SOURCE = `hello ${BADGE} world foo`; +const BADGE_START = 6; +const BADGE_END = 25; + +describe('badgeAwareWordJump', () => { + it('returns null when the buffer has no badge', () => { + expect(badgeAwareWordJump('hello world', 0, 'forward')).toBeNull(); + expect(badgeAwareWordJump('hello world', 11, 'backward')).toBeNull(); + }); + + it('jumps forward onto a badge landing at its end, not the next word', () => { + expect(badgeAwareWordJump(SOURCE, BADGE_START, 'forward')).toBe(BADGE_END); + }); + + it('jumps forward from the space before a badge landing at its end', () => { + expect(badgeAwareWordJump(SOURCE, BADGE_START - 1, 'forward')).toBe(BADGE_END); + }); + + it('jumps backward over a badge landing at its start', () => { + expect(badgeAwareWordJump(SOURCE, BADGE_END, 'backward')).toBe(BADGE_START); + }); + + it('jumps backward from the next word onto the badge start', () => { + // caret at the start of "world" + expect(badgeAwareWordJump(SOURCE, BADGE_END + 1, 'backward')).toBe(BADGE_START); + }); + + it('returns null for jumps that cross no badge', () => { + // forward over "hello" only + expect(badgeAwareWordJump(SOURCE, 0, 'forward')).toBeNull(); + // backward over "foo" only + expect(badgeAwareWordJump(SOURCE, SOURCE.length, 'backward')).toBeNull(); + // backward away from the badge (over "hello") + expect(badgeAwareWordJump(SOURCE, BADGE_START, 'backward')).toBeNull(); + }); + + it('treats a leading badge as one word in both directions', () => { + const source = `${BADGE} rest`; + expect(badgeAwareWordJump(source, 0, 'forward')).toBe(BADGE.length); + expect(badgeAwareWordJump(source, BADGE.length, 'backward')).toBe(0); + }); + + it('treats adjacent badges as separate words', () => { + // each badge is 14 chars: "[a](file:///x)" / "[b](file:///y)" + const source = '[a](file:///x)[b](file:///y)'; + expect(badgeAwareWordJump(source, 0, 'forward')).toBe(14); + expect(badgeAwareWordJump(source, 14, 'forward')).toBe(28); + expect(badgeAwareWordJump(source, 28, 'backward')).toBe(14); + expect(badgeAwareWordJump(source, 14, 'backward')).toBe(0); + }); + + it('jumps over a badge following punctuation', () => { + // "foo," 0-3, " " 4, badge 5-23 (end 24), " bar" 24-27 + const source = `foo, ${BADGE} bar`; + expect(badgeAwareWordJump(source, 0, 'forward')).toBeNull(); + expect(badgeAwareWordJump(source, 3, 'forward')).toBe(24); + }); +}); + +describe('leadingBadgeEdgeOffset', () => { + it('returns 0 when the caret sits exactly at a leading badge end', () => { + expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length)).toBe(0); + }); + + it('returns null when the caret is anywhere else', () => { + expect(leadingBadgeEdgeOffset(`${BADGE} rest`, 0)).toBeNull(); + expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length + 2)).toBeNull(); + }); + + it('returns null when the buffer does not start with a badge', () => { + expect(leadingBadgeEdgeOffset(SOURCE, BADGE_END)).toBeNull(); + expect(leadingBadgeEdgeOffset('', 0)).toBeNull(); + }); +}); diff --git a/tools/ui/tests/unit/mention-badge.test.ts b/tools/ui/tests/unit/mention-badge.test.ts index 03a0d5ba47..f2c0041829 100644 --- a/tools/ui/tests/unit/mention-badge.test.ts +++ b/tools/ui/tests/unit/mention-badge.test.ts @@ -3,12 +3,12 @@ import { MENTION_BADGE_FILE_ICON_PATHS, MENTION_BADGE_FOLDER_ICON_PATHS, buildMentionInsertion, + containsFileMentionLink, decodeFileLinkPath, encodeFileLinkPath, fileMentionLinkRe, getMentionBadgeIconPaths, - getMentionBadgeLabel, - mentionLinkEndingAt + getMentionBadgeLabel } from '$lib/utils'; import { FileMentionEntryType } from '$lib/enums'; @@ -35,6 +35,7 @@ describe('encodeFileLinkPath', () => { describe('fileMentionLinkRe', () => { it('matches a standard mention link', () => { expect(fileMentionLinkRe().test('[docs](file:///a/b)')).toBe(true); + expect(containsFileMentionLink('[docs](file:///a/b)')).toBe(true); }); it('does not match non-file links', () => { @@ -171,32 +172,3 @@ describe('buildMentionInsertion', () => { expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 2, end: 1 })).toBeNull(); }); }); - -describe('mentionLinkEndingAt', () => { - const LINK = '[docs](file:///a/b)'; - - it('returns the extent when the caret is exactly at the link end', () => { - expect(mentionLinkEndingAt(`see ${LINK} here`, 4 + LINK.length)).toEqual({ - start: 4, - end: 4 + LINK.length - }); - }); - - it('returns null when the caret is inside or past the link', () => { - expect(mentionLinkEndingAt(LINK, LINK.length - 1)).toBeNull(); - expect(mentionLinkEndingAt(`${LINK} `, LINK.length + 1)).toBeNull(); - }); - - it('returns null for non-file links and plain text', () => { - expect(mentionLinkEndingAt('[foo](https://example.com)', 26)).toBeNull(); - expect(mentionLinkEndingAt('plain', 5)).toBeNull(); - }); - - it('picks the link that ends at the caret when several exist', () => { - const value = `${LINK} and ${LINK}`; - expect(mentionLinkEndingAt(value, value.length)).toEqual({ - start: value.length - LINK.length, - end: value.length - }); - }); -}); diff --git a/tools/ui/tests/unit/source-history.test.ts b/tools/ui/tests/unit/source-history.test.ts new file mode 100644 index 0000000000..b83030941b --- /dev/null +++ b/tools/ui/tests/unit/source-history.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { SourceHistory } from '$lib/utils'; + +describe('SourceHistory', () => { + it('coalesces pushes inside the group window into one undo step', () => { + const h = new SourceHistory(100, 800); + h.push({ value: '', caret: 0 }, 1000); + h.push({ value: 'a', caret: 1 }, 1200); + h.push({ value: 'ab', caret: 2 }, 1500); + + expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 }); + expect(h.undo({ value: '', caret: 0 })).toBeNull(); + }); + + it('starts a new group once the window has passed', () => { + const h = new SourceHistory(100, 800); + h.push({ value: '', caret: 0 }, 1000); + h.push({ value: 'abc', caret: 3 }, 2000); + + expect(h.undo({ value: 'abcdef', caret: 6 })).toEqual({ value: 'abc', caret: 3 }); + expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 }); + }); + + it('newGroup forces a separate entry even inside the window', () => { + const h = new SourceHistory(100, 800); + h.push({ value: '', caret: 0 }, 1000); + h.push({ value: 'abc', caret: 3 }, 1100, true); + + expect(h.undo({ value: 'abc\n', caret: 4 })).toEqual({ value: 'abc', caret: 3 }); + expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 }); + }); + + it('redo round-trips and a fresh push clears the redo stack', () => { + const h = new SourceHistory(100, 800); + h.push({ value: '', caret: 0 }, 1000); + + const undone = h.undo({ value: 'abc', caret: 3 }); + expect(undone).toEqual({ value: '', caret: 0 }); + expect(h.redo({ value: '', caret: 0 })).toEqual({ value: 'abc', caret: 3 }); + + h.undo({ value: 'abc', caret: 3 }); + h.push({ value: '', caret: 0 }, 5000); + expect(h.redo({ value: 'x', caret: 1 })).toBeNull(); + }); + + it('starts a new group on the first edit after an undo', () => { + const h = new SourceHistory(100, 800); + h.push({ value: '', caret: 0 }, 1000); + h.undo({ value: 'abc', caret: 3 }); + + h.push({ value: '', caret: 0 }, 1200); + expect(h.undo({ value: 'x', caret: 1 })).toEqual({ value: '', caret: 0 }); + }); + + it('evicts the oldest entry past the limit', () => { + const h = new SourceHistory(2, 800); + h.push({ value: 'one', caret: 0 }, 1000); + h.push({ value: 'two', caret: 0 }, 2000); + h.push({ value: 'three', caret: 0 }, 3000); + + expect(h.undo({ value: 'cur', caret: 0 })).toEqual({ value: 'three', caret: 0 }); + expect(h.undo({ value: 'three', caret: 0 })).toEqual({ value: 'two', caret: 0 }); + expect(h.undo({ value: 'two', caret: 0 })).toBeNull(); + }); +}); From 3653e6d6d547ec763317d9ecd0ace334a7e21359 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 7 Aug 2026 22:35:52 +0200 Subject: [PATCH 056/210] tts: account for the vocoder pass in the timings line (#26733) get_output runs the waveform work the pipeline defers to it, from a single trailing window to a full pass depending on the model. Measuring it keeps the reported total and the audio to process ratio honest. --- tools/tts/tts.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/tts/tts.cpp b/tools/tts/tts.cpp index b68edcaf57..49c405e147 100644 --- a/tools/tts/tts.cpp +++ b/tools/tts/tts.cpp @@ -179,17 +179,20 @@ int main(int argc, char ** argv) { const char * data = nullptr; size_t data_len = 0; int64_t n_samples = 0; + const int64_t t_wav_start_us = ggml_time_us(); if (gen.get_output(&sample_rate, &data, &data_len, &n_samples) != 0) { LOG_ERR("get_output failed\n"); return 1; } + const double t_wav_s = (ggml_time_us() - t_wav_start_us) / 1e6; LOG_INF("generated %d frames, %zu bytes of WAV audio (%d Hz)\n", n_frames, data_len, sample_rate); const double t_prompt_s = (t_gen_start_us - t_prompt_start_us) / 1e6; - const double t_total_s = t_prompt_s + t_gen_s; + const double t_total_s = t_prompt_s + t_gen_s + t_wav_s; const double audio_s = sample_rate > 0 ? (double) n_samples / sample_rate : 0.0; - LOG_INF("timings: prompt eval %.2fs + generation %.2fs = total %.2fs\n", t_prompt_s, t_gen_s, t_total_s); + LOG_INF("timings: prompt eval %.2fs + generation %.2fs + vocoder %.2fs = total %.2fs\n", + t_prompt_s, t_gen_s, t_wav_s, t_total_s); LOG_INF(" output audio = %.2fs (audio time = %.2fx process time)\n", audio_s, t_total_s > 0 ? audio_s / t_total_s : 0.0); FILE * f = fopen(params.out_file.c_str(), "wb"); if (!f) { From 69bf6437914596fbbc4caf09a7ac16f2acdd1a94 Mon Sep 17 00:00:00 2001 From: Rafail Giavrimis <47496212+grafail@users.noreply.github.com> Date: Sat, 8 Aug 2026 05:40:04 +0100 Subject: [PATCH 057/210] CUDA: fix thread/block count in quantized cpy kernel launches (#26731) * CUDA: fix thread/block count in quantized cpy kernel launches * tests: add uneven block count cpy case --- ggml/src/ggml-cuda/cpy.cu | 44 +++++++++++++++++++------------------- tests/test-backend-ops.cpp | 3 +++ 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/ggml/src/ggml-cuda/cpy.cu b/ggml/src/ggml-cuda/cpy.cu index eb5eb0eb4e..fd7ffc0bc5 100644 --- a/ggml/src/ggml-cuda/cpy.cu +++ b/ggml/src/ggml-cuda/cpy.cu @@ -253,9 +253,9 @@ static void ggml_cpy_f32_q8_0_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { GGML_ASSERT(ne % QK8_0 == 0); - const int64_t num_blocks = ne / QK8_0; + const int64_t num_blocks = (ne/QK8_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_f32_q<<>> + cpy_f32_q<<>> (cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -264,9 +264,9 @@ static void ggml_cpy_q8_0_f32_cuda( const int64_t ne00, const int64_t ne01, const int64_t ne02, const int64_t nb00, const int64_t nb01, const int64_t nb02, const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { - const int64_t num_blocks = ne; + const int64_t num_blocks = (ne/QK8_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_q_f32<<>> + cpy_q_f32<<>> (cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -276,9 +276,9 @@ static void ggml_cpy_f32_q4_0_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { GGML_ASSERT(ne % QK4_0 == 0); - const int64_t num_blocks = ne / QK4_0; + const int64_t num_blocks = (ne/QK4_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_f32_q<<>> + cpy_f32_q<<>> (cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -289,9 +289,9 @@ static void ggml_cpy_q4_0_f32_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { - const int64_t num_blocks = ne; + const int64_t num_blocks = (ne/QK4_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_q_f32, QK4_0><<>>( + cpy_q_f32, QK4_0><<>>( cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -302,9 +302,9 @@ static void ggml_cpy_f32_q4_1_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { GGML_ASSERT(ne % QK4_1 == 0); - const int64_t num_blocks = ne / QK4_1; + const int64_t num_blocks = (ne/QK4_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_f32_q<<>> + cpy_f32_q<<>> (cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -315,9 +315,9 @@ static void ggml_cpy_q4_1_f32_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { - const int64_t num_blocks = ne; + const int64_t num_blocks = (ne/QK4_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_q_f32, QK4_1><<>>( + cpy_q_f32, QK4_1><<>>( cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -328,9 +328,9 @@ static void ggml_cpy_f32_q5_0_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { GGML_ASSERT(ne % QK5_0 == 0); - const int64_t num_blocks = ne / QK5_0; + const int64_t num_blocks = (ne/QK5_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_f32_q<<>> + cpy_f32_q<<>> (cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -341,9 +341,9 @@ static void ggml_cpy_q5_0_f32_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { - const int64_t num_blocks = ne; + const int64_t num_blocks = (ne/QK5_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_q_f32, QK5_0><<>>( + cpy_q_f32, QK5_0><<>>( cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -354,9 +354,9 @@ static void ggml_cpy_f32_q5_1_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { GGML_ASSERT(ne % QK5_1 == 0); - const int64_t num_blocks = ne / QK5_1; + const int64_t num_blocks = (ne/QK5_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_f32_q<<>> + cpy_f32_q<<>> (cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -367,9 +367,9 @@ static void ggml_cpy_q5_1_f32_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { - const int64_t num_blocks = ne; + const int64_t num_blocks = (ne/QK5_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_q_f32, QK5_1><<>>( + cpy_q_f32, QK5_1><<>>( cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } @@ -380,9 +380,9 @@ static void ggml_cpy_f32_iq4_nl_cuda( const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) { GGML_ASSERT(ne % QK4_NL == 0); - const int64_t num_blocks = ne / QK4_NL; + const int64_t num_blocks = (ne/QK4_NL + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE; GGML_ASSERT(num_blocks <= INT_MAX); - cpy_f32_q<<>> + cpy_f32_q<<>> (cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13); } diff --git a/tests/test-backend-ops.cpp b/tests/test-backend-ops.cpp index 6d474bc114..6688debd4a 100644 --- a/tests/test-backend-ops.cpp +++ b/tests/test-backend-ops.cpp @@ -8576,6 +8576,9 @@ static std::vector> make_test_cases_eval() { test_cases.emplace_back(new test_cpy(type_src, type_dst, {256, 2, 3, 4}, {-1,-1,-1,-1}, {1, 0, 2, 3})); // cpy not-contiguous } } + // quant block count not a multiple of the kernel block size + test_cases.emplace_back(new test_cpy(GGML_TYPE_F32, GGML_TYPE_Q4_0, {96, 1, 1, 1})); + test_cases.emplace_back(new test_cpy(GGML_TYPE_Q4_0, GGML_TYPE_F32, {96, 1, 1, 1})); test_cases.emplace_back(new test_cpy(GGML_TYPE_F32, GGML_TYPE_I32, {256, 2, 3, 4})); test_cases.emplace_back(new test_cpy(GGML_TYPE_F32, GGML_TYPE_I32, {256, 2, 3, 4}, {-1,-1,-1,-1}, {1, 0, 2, 3})); test_cases.emplace_back(new test_cpy(GGML_TYPE_I32, GGML_TYPE_F32, {256, 2, 3, 4})); From dd2c7c44710e860a428b46a92e2a9e39c428628b Mon Sep 17 00:00:00 2001 From: Xuan-Son Nguyen Date: Sat, 8 Aug 2026 16:35:53 +0200 Subject: [PATCH 058/210] server: add initial tool isolation support (via docker) (#26507) * server: add initial tool isolation support (via docker) * add docs * adapt get_info * py: fix type check * cont * separate tools_io_sandbox / tools_io_docker * rename sandbox --> isolate * x-tool-docker --> x-tool-runtime --------- Co-authored-by: Pascal --- common/arg.cpp | 10 + common/common.h | 1 + tools/cli/README.md | 1 - tools/completion/README.md | 1 - tools/server/README-dev.md | 1 + tools/server/README.md | 3 +- tools/server/server-tools.cpp | 567 +++++++++++++++--- tools/server/server-tools.h | 11 +- tools/server/server.cpp | 5 +- tools/server/tests/unit/test_tools_builtin.py | 91 +++ tools/server/tests/utils.py | 3 + 11 files changed, 613 insertions(+), 81 deletions(-) diff --git a/common/arg.cpp b/common/arg.cpp index da40874740..4cb853c7a4 100644 --- a/common/arg.cpp +++ b/common/arg.cpp @@ -3308,6 +3308,16 @@ common_params_context common_params_parser_init(common_params & params, llama_ex params.server_tools = parse_csv_row(value); } ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_TOOLS")); + add_opt(common_arg( + {"--tools-runtime"}, "OPTION", + "experimental: run tools in a separate runtime environment (default: none, use host environment)\n" + "available options:\n" + " 'docker:': spin up a new Docker container and reuse it for all invocations, clean up on server exit\n" + " 'docker-container:': use an existing Docker container by ID, won't stop on server exit\n", + [](common_params & params, const std::string & value) { + params.server_tools_runtime = value; + } + ).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_TOOLS_RUNTIME")); add_opt(common_arg( {"--mcp-servers-config"}, "PATH", "experimental: path to JSON file with MCP server definitions (Cursor-compatible format) - do not enable in untrusted environments (default: none)\n" diff --git a/common/common.h b/common/common.h index 2e15ec3f81..4811345f98 100644 --- a/common/common.h +++ b/common/common.h @@ -655,6 +655,7 @@ struct common_params { // enable built-in tools std::vector server_tools; + std::string server_tools_runtime; // MCP server configs (Cursor-compatible JSON) std::string mcp_servers_config; // path to JSON file with MCP server definitions diff --git a/tools/cli/README.md b/tools/cli/README.md index 4d86ce7c01..640d4fee80 100644 --- a/tools/cli/README.md +++ b/tools/cli/README.md @@ -54,7 +54,6 @@ | `-ctv, --cache-type-v TYPE` | KV cache data type for V
allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1
(default: f16)
(env: LLAMA_ARG_CACHE_TYPE_V) | | `-dt, --defrag-thold N` | KV cache defragmentation threshold (DEPRECATED)
(env: LLAMA_ARG_DEFRAG_THOLD) | | `-np, --parallel N` | number of parallel sequences to decode (default: 1)
(env: LLAMA_ARG_N_PARALLEL) | -| `--rpc SERVERS` | comma-separated list of RPC servers (host:port)
(env: LLAMA_ARG_RPC) | | `--mlock` | DEPRECATED in favor of `--load-mode`: force system to keep model in RAM rather than swapping or compressing
(env: LLAMA_ARG_MLOCK) | | `--mmap, --no-mmap` | DEPRECATED in favor of `--load-mode`: whether to memory-map model. (if mmap disabled, slower load but may reduce pageouts if not using mlock)
(env: LLAMA_ARG_MMAP) | | `-dio, --direct-io, -ndio, --no-direct-io` | DEPRECATED in favor of `--load-mode`: use DirectIO if available
(env: LLAMA_ARG_DIO) | diff --git a/tools/completion/README.md b/tools/completion/README.md index 2abe7aaa25..e0923ea300 100644 --- a/tools/completion/README.md +++ b/tools/completion/README.md @@ -137,7 +137,6 @@ llama-completion.exe -m models\gemma-1.1-7b-it.Q4_K_M.gguf --ignore-eos -n -1 | `-ctv, --cache-type-v TYPE` | KV cache data type for V
allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1
(default: f16)
(env: LLAMA_ARG_CACHE_TYPE_V) | | `-dt, --defrag-thold N` | KV cache defragmentation threshold (DEPRECATED)
(env: LLAMA_ARG_DEFRAG_THOLD) | | `-np, --parallel N` | number of parallel sequences to decode (default: 1)
(env: LLAMA_ARG_N_PARALLEL) | -| `--rpc SERVERS` | comma-separated list of RPC servers (host:port)
(env: LLAMA_ARG_RPC) | | `--mlock` | DEPRECATED in favor of `--load-mode`: force system to keep model in RAM rather than swapping or compressing
(env: LLAMA_ARG_MLOCK) | | `--mmap, --no-mmap` | DEPRECATED in favor of `--load-mode`: whether to memory-map model. (if mmap disabled, slower load but may reduce pageouts if not using mlock)
(env: LLAMA_ARG_MMAP) | | `-dio, --direct-io, -ndio, --no-direct-io` | DEPRECATED in favor of `--load-mode`: use DirectIO if available
(env: LLAMA_ARG_DIO) | diff --git a/tools/server/README-dev.md b/tools/server/README-dev.md index 45bcdcca76..31408f4267 100644 --- a/tools/server/README-dev.md +++ b/tools/server/README-dev.md @@ -201,6 +201,7 @@ Invoke a tool call, request body is a JSON object with: Headers: - `x-tool-cwd`: optional; if set, use as the CWD for tool; this is not part of tool's params because it's meant to be set by the runtime, not the LLM itself +- `x-tool-runtime`: optional; if set, run the tool inside this isolate instead of on the host. Only `docker-container:` is supported for now, using an already-running container Returns JSON object. There are two response formats (MCP tools use the same two formats: their result content is concatenated into `plain_text_response`, and RPC or tool errors are surfaced as the `error` string): diff --git a/tools/server/README.md b/tools/server/README.md index 4d80f059d5..64f0b03269 100644 --- a/tools/server/README.md +++ b/tools/server/README.md @@ -71,7 +71,6 @@ For the full list of features, please refer to [server's changelog](https://gith | `-ctk, --cache-type-k TYPE` | KV cache data type for K
allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1
(default: f16)
(env: LLAMA_ARG_CACHE_TYPE_K) | | `-ctv, --cache-type-v TYPE` | KV cache data type for V
allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1
(default: f16)
(env: LLAMA_ARG_CACHE_TYPE_V) | | `-dt, --defrag-thold N` | KV cache defragmentation threshold (DEPRECATED)
(env: LLAMA_ARG_DEFRAG_THOLD) | -| `--rpc SERVERS` | comma-separated list of RPC servers (host:port)
(env: LLAMA_ARG_RPC) | | `--mlock` | DEPRECATED in favor of `--load-mode`: force system to keep model in RAM rather than swapping or compressing
(env: LLAMA_ARG_MLOCK) | | `--mmap, --no-mmap` | DEPRECATED in favor of `--load-mode`: whether to memory-map model. (if mmap disabled, slower load but may reduce pageouts if not using mlock)
(env: LLAMA_ARG_MMAP) | | `-dio, --direct-io, -ndio, --no-direct-io` | DEPRECATED in favor of `--load-mode`: use DirectIO if available
(env: LLAMA_ARG_DIO) | @@ -198,6 +197,8 @@ For the full list of features, please refer to [server's changelog](https://gith | `--ui-config, --webui-config JSON` | JSON that provides default UI settings (overrides UI defaults)
(env: LLAMA_ARG_UI_CONFIG) | | `--ui-config-file, --webui-config-file PATH` | JSON file that provides default UI settings (overrides UI defaults)
(env: LLAMA_ARG_UI_CONFIG_FILE) | | `--ui-mcp-proxy, --webui-mcp-proxy, --no-ui-mcp-proxy, --no-webui-mcp-proxy` | experimental: whether to enable MCP CORS proxy - do not enable in untrusted environments (default: disabled)
(env: LLAMA_ARG_UI_MCP_PROXY) | +| `--tools TOOL1,TOOL2,...` | experimental: whether to enable built-in tools for AI agents - do not enable in untrusted environments (default: no tools)
specify "all" to enable all tools
available tools: read_file, file_glob_search, grep_search, exec_shell_command, write_file, edit_file, get_datetime
note: for security reasons, this will limit --cors-origins to localhost by default
(env: LLAMA_ARG_TOOLS) | +| `--tools-runtime OPTION` | experimental: run tools in a separate runtime environment (default: none, use host environment)
available options:
'docker:': spin up a new Docker container and reuse it for all invocations, clean up on server exit
'docker-container:': use an existing Docker container by ID, won't stop on server exit

(env: LLAMA_ARG_TOOLS_RUNTIME) | | `--tools TOOL1,TOOL2,...` | experimental: whether to enable built-in tools for AI agents - do not enable in untrusted environments (default: no tools)
specify "all" to enable all tools
available tools: read_file, file_glob_search, grep_search, exec_shell_command, write_file, edit_file, get_datetime, get_info
note: for security reasons, this will limit --cors-origins to localhost by default
(env: LLAMA_ARG_TOOLS) | | `--mcp-servers-config PATH` | experimental: path to JSON file with MCP server definitions (Cursor-compatible format) - do not enable in untrusted environments (default: none)
note: for security reasons, this will limit --cors-origins to localhost by default
(env: LLAMA_ARG_MCP_SERVERS_CONFIG) | | `--mcp-servers-json JSON` | experimental: inline JSON with MCP server definitions (Cursor-compatible format) - do not enable in untrusted environments (default: none)
note: for security reasons, this will limit --cors-origins to localhost by default
(env: LLAMA_ARG_MCP_SERVERS_JSON) | diff --git a/tools/server/server-tools.cpp b/tools/server/server-tools.cpp index 2fcb2a3c88..eacfbf0f74 100644 --- a/tools/server/server-tools.cpp +++ b/tools/server/server-tools.cpp @@ -10,12 +10,15 @@ #include #include #include +#include #include #include +#include #include #include #include #include +#include #if defined(_WIN32) # ifndef NOMINMAX @@ -127,6 +130,13 @@ static int entry_depth(const std::string & rel) { return 1 + (int) std::count(rel.begin(), rel.end(), '/'); } +// directories that a listing reports but never descends into: they can be enormous +// lowercase only, the local walker case-folds a name before the lookup +static const char * const SERVER_TOOL_JUNK_DIR_NAMES[] = { + ".git", ".svn", ".hg", "node_modules", "__pycache__", + ".venv", "venv", "dist", "build", "target", ".cache", ".idea", ".vscode", +}; + class tools_io { public: struct exec_result { @@ -165,6 +175,85 @@ public: const std::function & on_chunk = nullptr) const = 0; }; +// shared subprocess execution helper, used by both the local and the docker-backed tools_io implementations. +// combine_stderr=false when the raw stdout bytes must not be tainted by stderr, e.g. reading file contents. +static tools_io::exec_result run_subprocess( + const std::vector & args, + size_t max_output, + int timeout_secs, + const std::function & on_chunk, + bool combine_stderr, + const std::string & cwd = "") { + tools_io::exec_result res; + + common_subproc proc; + + int options = subprocess_option_no_window + | subprocess_option_inherit_environment + | subprocess_option_search_user_path; + if (combine_stderr) { + options |= subprocess_option_combined_stdout_stderr; + } + + if (!proc.create(args, options, {}, cwd.empty() ? nullptr : cwd.c_str())) { + res.output = "failed to spawn process"; + return res; + } + + std::atomic done{false}; + std::atomic timed_out{false}; + + std::thread timeout_thread([&]() { + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_secs); + while (!done.load()) { + if (std::chrono::steady_clock::now() >= deadline) { + timed_out.store(true); + proc.terminate(); + return; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + }); + + FILE * f = proc.stdout_file(); + std::string output; + bool truncated = false; + if (f) { + char buf[4096]; + while (fgets(buf, sizeof(buf), f) != nullptr) { + if (!truncated) { + size_t len = strlen(buf); + if (output.size() + len <= max_output) { + output.append(buf, len); + if (on_chunk && !on_chunk(console_output_to_utf8(std::string(buf, len)))) { + proc.terminate(); + break; + } + } else { + size_t remaining = max_output - output.size(); + output.append(buf, remaining); + if (on_chunk && remaining > 0) on_chunk(console_output_to_utf8(std::string(buf, remaining))); + truncated = true; + } + } + } + } + + done.store(true); + if (timeout_thread.joinable()) { + timeout_thread.join(); + } + + res.exit_code = proc.join(); + + res.output = console_output_to_utf8(output); + res.timed_out = timed_out.load(); + if (truncated) { + res.output += "\n[output truncated]"; + } + return res; +} + class tools_io_basic : public tools_io { public: // cwd, if non-empty, is used to resolve relative paths and as the working directory for run() @@ -276,72 +365,7 @@ public: size_t max_output, int timeout_secs, const std::function & on_chunk = nullptr) const override { - exec_result res; - - common_subproc proc; - - int options = subprocess_option_no_window - | subprocess_option_combined_stdout_stderr - | subprocess_option_inherit_environment - | subprocess_option_search_user_path; - - if (!proc.create(args, options, {}, cwd.empty() ? nullptr : cwd.c_str())) { - res.output = "failed to spawn process"; - return res; - } - - std::atomic done{false}; - std::atomic timed_out{false}; - - std::thread timeout_thread([&]() { - auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_secs); - while (!done.load()) { - if (std::chrono::steady_clock::now() >= deadline) { - timed_out.store(true); - proc.terminate(); - return; - } - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - }); - - FILE * f = proc.stdout_file(); - std::string output; - bool truncated = false; - if (f) { - char buf[4096]; - while (fgets(buf, sizeof(buf), f) != nullptr) { - if (!truncated) { - size_t len = strlen(buf); - if (output.size() + len <= max_output) { - output.append(buf, len); - if (on_chunk && !on_chunk(console_output_to_utf8(std::string(buf, len)))) { - proc.terminate(); - break; - } - } else { - size_t remaining = max_output - output.size(); - output.append(buf, remaining); - if (on_chunk && remaining > 0) on_chunk(console_output_to_utf8(std::string(buf, remaining))); - truncated = true; - } - } - } - } - - done.store(true); - if (timeout_thread.joinable()) { - timeout_thread.join(); - } - - res.exit_code = proc.join(); - - res.output = console_output_to_utf8(output); - res.timed_out = timed_out.load(); - if (truncated) { - res.output += "\n[output truncated]"; - } - return res; + return run_subprocess(args, max_output, timeout_secs, on_chunk, /*combine_stderr=*/true, cwd); } private: @@ -384,10 +408,8 @@ private: } static const std::unordered_set & junk_dir_names() { - static const std::unordered_set names = { - ".git", ".svn", ".hg", "node_modules", "__pycache__", - ".venv", "venv", "dist", "build", "target", ".cache", ".idea", ".vscode", - }; + static const std::unordered_set names( + std::begin(SERVER_TOOL_JUNK_DIR_NAMES), std::end(SERVER_TOOL_JUNK_DIR_NAMES)); return names; } @@ -450,9 +472,274 @@ private: } }; +// timeout for auxiliary isolate calls (stat/mkdir/ls/cp helpers); exec_shell_command uses its own +// caller-controlled timeout instead, enforced separately in run() +static constexpr int SERVER_TOOL_ISOLATE_EXEC_TIMEOUT = 15; // seconds +static constexpr size_t SERVER_TOOL_ISOLATE_READ_FILE_MAX_SIZE = 64 * 1024 * 1024; // 64 MB + +// runs every tools_io operation as a command inside an isolate: a container, a remote host, ... +// the isolate is created, mounted, and torn down externally by the caller +// it must provide a POSIX environment: sh, cat, wc, mkdir, dirname, find, timeout +class tools_io_isolate : public tools_io { +public: + // cwd, if non-empty, is used to resolve relative paths and as the working directory for run() + explicit tools_io_isolate(std::string cwd = "") : cwd(std::move(cwd)) {} + + // resolves `path` against `cwd` if `path` is relative and `cwd` is set; otherwise returns `path` unchanged. + // isolate paths are always POSIX-style ('/'), regardless of host OS. + std::string resolve(const std::string & path) const override { + if (cwd.empty() || (!path.empty() && path[0] == '/')) { + return path; + } + return cwd + "/" + path; + } + + bool is_directory(const std::string & path) const override { + return shell_test("-d", resolve(path)); + } + + bool is_regular_file(const std::string & path) const override { + return shell_test("-f", resolve(path)); + } + + bool file_size(const std::string & path, uintmax_t & out_size) const override { + auto res = exec({"sh", "-c", "wc -c < \"$1\"", "_", resolve(path)}, 64, true); + if (res.exit_code != 0 || res.timed_out) return false; + try { + size_t pos; + out_size = (uintmax_t) std::stoull(res.output, &pos); + } catch (...) { + return false; + } + return true; + } + + bool read_file(const std::string & path, std::string & out) const override { + // combine_stderr=false: stderr must not be spliced into raw file bytes + auto res = exec({"cat", "--", resolve(path)}, SERVER_TOOL_ISOLATE_READ_FILE_MAX_SIZE, false); + if (res.exit_code != 0 || res.timed_out) return false; + out = res.output; + return true; + } + + bool write_file(const std::string & path, const std::string & content) const override { + std::string abs_path = resolve(path); + + std::error_code ec; + fs::path tmp_dir = fs::temp_directory_path(ec); + if (ec) return false; + + static std::atomic tmp_counter{0}; + fs::path tmp = tmp_dir / string_format( + "llama-tools-io-isolate-%zu-%llu.tmp", + std::hash{}(std::this_thread::get_id()), + (unsigned long long) tmp_counter.fetch_add(1)); + + { + std::ofstream f(tmp, std::ios::binary); + if (!f) return false; + f << content; + if (!f) return false; + } + + bool ok = shell_run({"sh", "-c", "mkdir -p \"$(dirname \"$1\")\"", "_", abs_path}); + if (ok) { + ok = upload(tmp.string(), abs_path); + } + + std::error_code rm_ec; + fs::remove(tmp, rm_ec); + return ok; + } + + list_result list_entries(const std::string & base, int max_depth, list_kind kind) const override { + list_result out; + + const std::string abs_base = resolve(base); + if (!is_directory(base)) { + out.err = "path does not exist or is not a directory"; + return out; + } + + // git ls-files cannot list directories; use the walker when they are requested + if (kind == list_kind::files) { + auto res = exec( + {"sh", "-c", "cd \"$1\" && git ls-files --cached --others --exclude-standard", "_", abs_base}, + SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, true); + + if (res.exit_code == 0 && !res.timed_out) { + for (const auto & rel : split_lines(res.output, /*strip_dot_slash=*/false)) { + if (max_depth > 0 && entry_depth(rel) > max_depth) continue; + out.entries.push_back({rel, false}); + } + return out; + } + } + + if (kind == list_kind::dirs || kind == list_kind::all) { + for (auto & rel : find_entries(abs_base, max_depth, /*dirs=*/true, out.truncated)) { + out.entries.push_back({std::move(rel), true}); + } + } + if (kind == list_kind::files || kind == list_kind::all) { + for (auto & rel : find_entries(abs_base, max_depth, /*dirs=*/false, out.truncated)) { + out.entries.push_back({std::move(rel), false}); + } + } + + return out; + } + + // wraps the command with an in-isolate `timeout`, since killing the host-side client + // does not kill the process tree running inside the isolate + exec_result run( + const std::vector & args, + size_t max_output, + int timeout_secs, + const std::function & on_chunk = nullptr) const override { + std::vector inner = {"timeout", std::to_string(timeout_secs) + "s"}; + inner.insert(inner.end(), args.begin(), args.end()); + // small buffer over timeout_secs so the in-isolate `timeout` has a chance to exit cleanly + // before the host-side supervisory timeout forcibly kills the client + return run_subprocess( + build_argv(with_cwd(inner), /*needs_stdin=*/true), + max_output, timeout_secs + 5, on_chunk, true); + } + +protected: + // wrap `inner` (a complete POSIX argv) into the host-side argv that runs it in the isolate + // a transport that re-parses its args in a remote shell (ssh) must join `inner` with shell_quote_join() + virtual std::vector build_argv(const std::vector & inner, bool needs_stdin) const = 0; + + // copy a host file into the isolate, `isolate_path` is absolute and its parent already exists + virtual bool upload(const std::string & host_path, const std::string & isolate_path) const = 0; + + // quote `argv` into a single string that a POSIX shell re-parses into exactly `argv` + static std::string shell_quote_join(const std::vector & argv) { + std::string out; + for (const auto & arg : argv) { + if (!out.empty()) out += ' '; + out += '\''; + for (const char c : arg) { + // a single quote cannot be escaped inside single quotes: close, escape, reopen + if (c == '\'') out += "'\\''"; + else out += c; + } + out += '\''; + } + return out; + } + +private: + std::string cwd; + + // set the working directory in the command itself, docker's `-w` has no equivalent on every transport + // auxiliary calls do not need this, they use the absolute paths from resolve() + std::vector with_cwd(const std::vector & inner) const { + if (cwd.empty()) { + return inner; + } + // 127 is what a shell reports for a command it could not run + std::vector out = {"sh", "-c", "cd \"$1\" || exit 127; shift; exec \"$@\"", "_", cwd}; + out.insert(out.end(), inner.begin(), inner.end()); + return out; + } + + exec_result exec(const std::vector & inner, size_t max_output, bool combine_stderr) const { + return run_subprocess( + build_argv(inner, /*needs_stdin=*/false), + max_output, SERVER_TOOL_ISOLATE_EXEC_TIMEOUT, nullptr, combine_stderr); + } + + bool shell_run(const std::vector & inner) const { + auto res = exec(inner, 4096, true); + return res.exit_code == 0 && !res.timed_out; + } + + bool shell_test(const char * flag, const std::string & path) const { + return shell_run({"sh", "-c", std::string("[ ") + flag + " \"$1\" ]", "_", path}); + } + + static std::vector split_lines(const std::string & text, bool strip_dot_slash) { + std::vector result; + std::istringstream iss(text); + std::string line; + while (std::getline(iss, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.empty()) continue; + if (strip_dot_slash && line.rfind("./", 0) == 0) line = line.substr(2); + std::replace(line.begin(), line.end(), '\\', '/'); + result.push_back(line); + } + return result; + } + + // one `find` pass in the isolate. junk directories stay selectable but are never descended into, + // and -mindepth/-maxdepth keep a busybox image working as well as a GNU one + std::vector find_entries(const std::string & abs_base, int max_depth, bool dirs, bool & truncated) const { + std::string prune_expr; + for (const char * n : SERVER_TOOL_JUNK_DIR_NAMES) { + if (!prune_expr.empty()) prune_expr += " -o "; + prune_expr += std::string("-name ") + n; + } + + std::string cmd = "cd \"$1\" && find . -mindepth 1"; + if (max_depth > 0) { + cmd += " -maxdepth " + std::to_string(max_depth); + } + cmd += " \\( " + prune_expr + " \\) -prune"; + cmd += dirs ? " -print -o -type d -print" : " -o -type f -print"; + + auto res = exec({"sh", "-c", cmd, "_", abs_base}, SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, true); + truncated = truncated || res.timed_out; + return split_lines(res.output, /*strip_dot_slash=*/true); + } +}; + +// an already-running docker container, driven through `docker exec` and `docker cp` +class tools_io_docker : public tools_io_isolate { +public: + tools_io_docker(std::string container_id, std::string cwd = "") + : tools_io_isolate(std::move(cwd)), container_id(std::move(container_id)) {} + +protected: + std::vector build_argv(const std::vector & inner, bool needs_stdin) const override { + std::vector argv = {"docker", "exec"}; + if (needs_stdin) { + argv.push_back("-i"); + } + argv.push_back(container_id); + argv.insert(argv.end(), inner.begin(), inner.end()); + return argv; + } + + bool upload(const std::string & host_path, const std::string & isolate_path) const override { + auto res = run_subprocess( + {"docker", "cp", host_path, container_id + ":" + isolate_path}, + 4096, SERVER_TOOL_ISOLATE_EXEC_TIMEOUT, nullptr, true); + return res.exit_code == 0 && !res.timed_out; + } + +private: + std::string container_id; +}; + +// runtime spec used by --tools-runtime and the x-tool-runtime header +// this is the only scheme for now, ssh: and podman: can be added next to it +static const std::string SERVER_TOOL_RUNTIME_DOCKER_CONTAINER = "docker-container:"; + +// an empty runtime runs the tools on the host static std::unique_ptr make_tools_io(const json & params) { - std::string cwd = json_value(params, "cwd", std::string()); - return std::make_unique(cwd); + std::string cwd = json_value(params, "cwd", std::string()); + std::string runtime = json_value(params, "runtime", std::string()); + if (runtime.empty()) { + return std::make_unique(cwd); + } + if (runtime.rfind(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER, 0) == 0) { + return std::make_unique(runtime.substr(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER.size()), cwd); + } + // do not fall back to the host, the caller asked for an isolate + throw std::runtime_error("unknown tool runtime: " + runtime); } // no '/' in pattern -> match basename at any depth; else match full relative path @@ -861,8 +1148,11 @@ struct server_tool_exec_shell_command : server_tool { timeout = std::min(timeout, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT); max_output = std::min(max_output, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE); + // an isolate is always POSIX regardless of host OS, so it always gets `sh -c` #ifdef _WIN32 - std::vector args = {"cmd", "/c", command}; + std::vector args = !json_value(params, "runtime", std::string()).empty() + ? std::vector{"sh", "-c", command} + : std::vector{"cmd", "/c", command}; #else std::vector args = {"sh", "-c", command}; #endif @@ -1355,11 +1645,16 @@ struct server_tool_get_info : server_tool { json invoke(json params, server_tool::stream *) const override { auto io = make_tools_io(params); + // inside an isolate, we always use the linux command #ifdef _WIN32 - auto res = io->run({"cmd", "/c", "ver"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT); + std::vector args = !json_value(params, "runtime", std::string()).empty() + ? std::vector{"uname", "-a"} + : std::vector{"cmd", "/c", "ver"}; #else - auto res = io->run({"uname", "-a"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT); + std::vector args = {"uname", "-a"}; #endif + + auto res = io->run(args, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT); // "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 std::string os_info = res.exit_code == 0 && !res.timed_out ? string_strip(res.output) : "unknown"; @@ -1461,6 +1756,103 @@ struct server_mcp_tool : server_tool { } }; +// owns the docker container used as the sandboxed runtime for tool invocations, as configured by +// --tools-runtime. "spawned" mode starts and stops the container itself; "existing" mode just reuses +// a container id the user already has running and never stops it. +struct server_tools_docker_runtime { + server_tools_docker_runtime(const server_tools_docker_runtime &) = delete; + + explicit server_tools_docker_runtime(const std::string & spec) { + static const std::string docker_prefix = "docker:"; + if (spec.rfind(docker_prefix, 0) == 0) { + spawned = true; + image = spec.substr(docker_prefix.size()); + if (image.empty()) { + throw std::runtime_error("--tools-runtime docker: requires an image name"); + } + spawn(); + } else if (spec.rfind(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER, 0) == 0) { + spawned = false; + container_id = spec.substr(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER.size()); + if (container_id.empty()) { + throw std::runtime_error("--tools-runtime docker-container: requires a container id"); + } + } else { + throw std::runtime_error("unknown --tools-runtime option: " + spec); + } + } + + ~server_tools_docker_runtime() { + if (spawned && !container_id.empty()) { + // closing stdin signals the container's shell (its pid 1) to exit; --rm then removes it + proc.close_stdin(); + proc.join(); + } + } + + // container id to use for the next tool call; respawns a spawned container that died on its own, + // or throws if an externally-managed one is no longer reachable + std::string get_container_id() { + std::lock_guard lock(mutex); + if (!spawned) { + if (!is_running(container_id)) { + throw std::runtime_error(string_format( + "docker container \"%s\" is no longer running, restart it to keep using tools", + container_id.c_str())); + } + return container_id; + } + + if (!proc.alive()) { + SRV_WRN("docker tools runtime container \"%s\" died, respawning\n", container_id.c_str()); + spawn(); + } + return container_id; + } + +private: + bool spawned = false; + std::string image; // spawned mode only + std::string container_id; + common_subproc proc; // spawned mode only: `docker run` client that keeps the container alive + std::mutex mutex; + + // spawns "docker run --rm -i sh" and keeps its stdin open; the shell blocks reading stdin, + // so the container stays alive until we close it (see destructor) or it is killed from the outside + void spawn() { + std::error_code ec; + fs::path cidfile = fs::temp_directory_path(ec) / string_format( + "llama-tools-runtime-cid-%zu.tmp", std::hash{}(std::this_thread::get_id())); + fs::remove(cidfile, ec); + + std::vector args = {"docker", "run", "--rm", "-i", "--cidfile", cidfile.string(), image, "sh"}; + int options = subprocess_option_no_window + | subprocess_option_inherit_environment + | subprocess_option_search_user_path; + if (!proc.create(args, options)) { + throw std::runtime_error("failed to spawn docker container for tools runtime (image: " + image + ")"); + } + + std::string cid; + for (int i = 0; i < 100 && cid.empty(); i++) { + std::ifstream f(cidfile); + if (f) std::getline(f, cid); + if (cid.empty()) std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + fs::remove(cidfile, ec); + if (cid.empty()) { + proc.terminate(); + throw std::runtime_error("timed out waiting for docker container to start (image: " + image + ")"); + } + container_id = cid; + } + + static bool is_running(const std::string & id) { + auto res = run_subprocess({"docker", "inspect", "-f", "{{.State.Running}}", id}, 16, 5, nullptr, true); + return res.exit_code == 0 && !res.timed_out && res.output.rfind("true", 0) == 0; + } +}; + static server_tool & find_tool(std::vector> & tools, const std::string & name, bool require_stream) { for (auto & t : tools) { if (t->name == name) { @@ -1506,8 +1898,16 @@ static std::string get_header(const std::map & headers return default_value; } +server_tools::server_tools() = default; +server_tools::~server_tools() = default; + void server_tools::setup(const std::vector & enabled_tools, - server_mcp & mcp_mgr) { + server_mcp & mcp_mgr, + const std::string & tools_runtime) { + if (!tools_runtime.empty()) { + docker_runtime = std::make_unique(tools_runtime); + } + if (!enabled_tools.empty()) { if (!common_subproc::is_supported()) { throw std::runtime_error("subprocess is not enabled on this build"); @@ -1590,11 +1990,26 @@ void server_tools::setup(const std::vector & enabled_tools, bool stream = body.value("stream", false); // accept x-tool-cwd header to override of the process + if (params.contains("cwd")) { + params.erase("cwd"); + } auto cwd = get_header(req.headers, "x-tool-cwd"); if (!cwd.empty()) { params["cwd"] = cwd; } + // accept x-tool-runtime header to route tool I/O through an isolate, e.g. "docker-container:"; + // falls back to the --tools-runtime isolate, if configured + if (params.contains("runtime")) { + params.erase("runtime"); + } + auto runtime = get_header(req.headers, "x-tool-runtime"); + if (!runtime.empty()) { + params["runtime"] = runtime; + } else if (docker_runtime) { + params["runtime"] = SERVER_TOOL_RUNTIME_DOCKER_CONTAINER + docker_runtime->get_container_id(); + } + server_tool & tool = find_tool(tools, tool_name, stream); if (stream) { diff --git a/tools/server/server-tools.h b/tools/server/server-tools.h index 601399ee93..7f70e6767e 100644 --- a/tools/server/server-tools.h +++ b/tools/server/server-tools.h @@ -30,6 +30,8 @@ struct server_tool { json to_json() const; }; +struct server_tools_docker_runtime; // impl detail, defined in server-tools.cpp + struct server_tools { std::vector> tools; @@ -37,9 +39,16 @@ struct server_tools { server_response queue_res; std::atomic res_id{0}; + // set when --tools-runtime is configured; owns the docker container used to run tools, if any + std::unique_ptr docker_runtime; + void setup(const std::vector & enabled_tools, - server_mcp & mcp_mgr); + server_mcp & mcp_mgr, + const std::string & tools_runtime); server_http_context::handler_t handle_get; server_http_context::handler_t handle_post; + + server_tools(); + ~server_tools(); }; diff --git a/tools/server/server.cpp b/tools/server/server.cpp index aafb1f3079..1b2e6edb4e 100644 --- a/tools/server/server.cpp +++ b/tools/server/server.cpp @@ -338,7 +338,7 @@ int llama_server(common_params & params, int argc, char ** argv) { if (!params.server_tools.empty() || !mcp_mgr.empty()) { try { - tools.setup(params.server_tools, mcp_mgr); + tools.setup(params.server_tools, mcp_mgr, params.server_tools_runtime); } catch (const std::exception & e) { SRV_ERR("tools setup failed: %s\n", e.what()); return 1; @@ -348,6 +348,9 @@ int llama_server(common_params & params, int argc, char ** argv) { if (!params.server_tools.empty()) { warn_names.push_back("built-in tools (experimental)"); } + if (!params.server_tools_runtime.empty()) { + warn_names.push_back("tools runtime (experimental)"); + } if (!mcp_mgr.empty()) { warn_names.push_back("MCP servers (experimental)"); } diff --git a/tools/server/tests/unit/test_tools_builtin.py b/tools/server/tests/unit/test_tools_builtin.py index 11c82e690a..c651e8e72d 100755 --- a/tools/server/tests/unit/test_tools_builtin.py +++ b/tools/server/tests/unit/test_tools_builtin.py @@ -1,4 +1,6 @@ import os +import shutil +import subprocess import pytest from utils import * @@ -146,6 +148,95 @@ def test_tools_builtin_cwd_header(): os.remove(marker_path) +def _docker_unavailable_reason() -> str | None: + """None if docker can be used to run a container, otherwise the reason it can't.""" + docker_bin = shutil.which("docker") + if docker_bin is None: + return "docker is not installed" + try: + subprocess.run([docker_bin, "info"], capture_output=True, timeout=5, check=True) + except Exception as e: + return f"docker daemon is not usable: {e}" + return None + + +@pytest.fixture +def docker_container(): + reason = _docker_unavailable_reason() + if reason is not None: + pytest.skip(reason) # ty: ignore[too-many-positional-arguments, invalid-argument-type] + + proc = subprocess.run( + ["docker", "run", "-d", "--rm", "busybox", "sleep", "300"], + capture_output=True, text=True, + ) + if proc.returncode != 0: + pytest.skip(f"failed to start docker container: {proc.stderr.strip()}") # ty: ignore[too-many-positional-arguments, invalid-argument-type] + + container_id = proc.stdout.strip() + try: + yield container_id + finally: + subprocess.run(["docker", "rm", "-f", container_id], capture_output=True) + + +def test_tools_builtin_runtime_header(docker_container: str): + global server + server.start() + + headers = {"x-tool-runtime": f"docker-container:{docker_container}", "x-tool-cwd": "/tmp"} + + write_res = call_tool("write_file", {"path": "test.log", "content": "hello docker\n"}, headers=headers) + assert write_res["result"] == "file written successfully" + + read_res = call_tool("read_file", {"path": "test.log"}, headers=headers) + assert read_res["plain_text_response"] == "hello docker\n" + + exec_res = call_tool("exec_shell_command", {"command": "cat test.log"}, headers=headers) + assert "hello docker" in exec_res["plain_text_response"] + + +def test_tools_builtin_runtime_header_unknown_scheme(): + global server + server.start() + + # an unknown runtime must fail, never silently fall back to running on the host + res = server.make_request("POST", "/tools", + data={"tool": "exec_shell_command", "params": {"command": "echo hi"}}, + headers={"x-tool-runtime": "ssh:example.com"}) + assert res.status_code == 500, res.body + assert "unknown tool runtime" in str(res.body) + + +def test_tools_builtin_docker_runtime_cleans_up_spawned_container(): + reason = _docker_unavailable_reason() + if reason is not None: + pytest.skip(reason) # ty: ignore[too-many-positional-arguments, invalid-argument-type] + + global server + server.server_tools_runtime = "docker:busybox" + server.start() + + # exec_shell_command runs inside the container spawned for --tools-runtime; docker sets + # the container's hostname to its own short id, so this also tells us which one to check + res = call_tool("exec_shell_command", {"command": "hostname"}) + container_id = res["plain_text_response"].splitlines()[0].strip() + assert len(container_id) >= 8, res + + running = subprocess.run( + ["docker", "inspect", "-f", "{{.State.Running}}", container_id], + capture_output=True, text=True, + ) + assert running.returncode == 0 and running.stdout.strip() == "true", running.stderr + + server.stop() + + # a clean server shutdown must stop and remove the container it spawned (it runs with --rm), + # not leave it behind as an abandoned child + leftover = subprocess.run(["docker", "inspect", container_id], capture_output=True, text=True) + assert leftover.returncode != 0, f"container {container_id} was not cleaned up after server exit" + + def test_tools_builtin_edit_file_rejects_overlapping_edits(): global server server.start() diff --git a/tools/server/tests/utils.py b/tools/server/tests/utils.py index 3416f0bfde..fffe07a671 100644 --- a/tools/server/tests/utils.py +++ b/tools/server/tests/utils.py @@ -115,6 +115,7 @@ class ServerProcess: backend_sampling: bool = False gcp_compat: bool = False server_tools: str | None = None + server_tools_runtime: str | None = None mcp_servers_config: str | None = None mcp_servers_json: str | None = None cors_origins: str | None = None @@ -270,6 +271,8 @@ class ServerProcess: server_args.append("--ui-mcp-proxy") if self.server_tools: server_args.extend(["--tools", self.server_tools]) + if self.server_tools_runtime: + server_args.extend(["--tools-runtime", self.server_tools_runtime]) if self.mcp_servers_config: server_args.extend(["--mcp-servers-config", self.mcp_servers_config]) if self.mcp_servers_json: From 18f7ad7fc912444acc0f51995a4b8e45fd9a0cd4 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 8 Aug 2026 16:36:21 +0200 Subject: [PATCH 059/210] server, ui: only offer a working directory when a tool reads it (#26762) The working directory chip showed up as soon as the server exposed any builtin tool, so a server started with just get_datetime, or a user who turned every filesystem tool off in the settings, still got a control that nothing would read. Tools now declare whether they resolve their paths and run against the working directory, next to the write permission they already publish in the /tools listing. The WebUI shows the chip and enables the /cwd command only when at least one such tool is both served and left enabled. --- tools/server/server-tools.cpp | 8 +++++++ tools/server/server-tools.h | 1 + .../app/chat/ChatForm/ChatForm.svelte | 4 ++-- tools/ui/src/lib/constants/chat-commands.ts | 4 ++-- .../lib/hooks/use-chat-form-pickers.svelte.ts | 4 ++-- tools/ui/src/lib/stores/tools.svelte.ts | 21 +++++++++++++++++++ tools/ui/src/lib/types/mcp.d.ts | 1 + .../components/ChatFormPickersHarness.svelte | 2 +- 8 files changed, 38 insertions(+), 7 deletions(-) diff --git a/tools/server/server-tools.cpp b/tools/server/server-tools.cpp index eacfbf0f74..d5e696434c 100644 --- a/tools/server/server-tools.cpp +++ b/tools/server/server-tools.cpp @@ -74,6 +74,7 @@ json server_tool::to_json() const { {"permissions", json{ {"write", permission_write} }}, + {"uses_cwd", uses_cwd}, {"definition", get_definition()}, }; } @@ -763,6 +764,7 @@ struct server_tool_read_file : server_tool { server_tool_read_file() { name = "read_file"; display_name = "Read file"; + uses_cwd = true; permission_write = false; } @@ -851,6 +853,7 @@ struct server_tool_file_glob_search : server_tool { server_tool_file_glob_search() { name = "file_glob_search"; display_name = "File search"; + uses_cwd = true; permission_write = false; } @@ -965,6 +968,7 @@ struct server_tool_grep_search : server_tool { server_tool_grep_search() { name = "grep_search"; display_name = "Grep search"; + uses_cwd = true; permission_write = false; } @@ -1117,6 +1121,7 @@ struct server_tool_exec_shell_command : server_tool { server_tool_exec_shell_command() { name = "exec_shell_command"; display_name = "Execute shell command"; + uses_cwd = true; permission_write = true; support_stream = true; } @@ -1195,6 +1200,7 @@ struct server_tool_write_file : server_tool { server_tool_write_file() { name = "write_file"; display_name = "Write file"; + uses_cwd = true; permission_write = true; } @@ -1237,6 +1243,7 @@ struct server_tool_edit_file : server_tool { server_tool_edit_file() { name = "edit_file"; display_name = "Edit file"; + uses_cwd = true; permission_write = true; } @@ -1625,6 +1632,7 @@ struct server_tool_get_info : server_tool { server_tool_get_info() { name = "get_info"; display_name = "Get Runtime Info"; + uses_cwd = true; permission_write = false; } diff --git a/tools/server/server-tools.h b/tools/server/server-tools.h index 7f70e6767e..ede303181b 100644 --- a/tools/server/server-tools.h +++ b/tools/server/server-tools.h @@ -14,6 +14,7 @@ struct server_tool { std::string display_name; bool permission_write = false; bool support_stream = false; // if true, output can be streamed + bool uses_cwd = false; // if true, the tool resolves paths and runs against the working directory virtual ~server_tool() = default; virtual json get_definition() const = 0; 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 1df1257089..2d70c302cb 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -156,7 +156,7 @@ focusInput: refocusInput, getShowModelSelector: () => showModelSelector, hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()), - hasBuiltinTools: () => toolsStore.builtinTools.length > 0, + hasCwdTools: () => toolsStore.hasEnabledCwdTools, getCwd: () => cwd, getServerHome: () => toolsStore.serverHome ?? null, openModelSelector: () => chatFormActionsRef?.openModelSelector(), @@ -651,7 +651,7 @@ - {#if toolsStore.builtinTools.length > 0} + {#if toolsStore.hasEnabledCwdTools} boolean; /** Gates `/cwd`. */ - hasBuiltinTools: () => boolean; + hasCwdTools: () => boolean; } /** @@ -32,7 +32,7 @@ export function getChatCommands(options: ChatCommandsOptions): ChatFormCommand[] description: SET_WORKING_DIRECTORY_LABEL, keywords: ['current working directory'], action: ChatFormCommandAction.CWD, - disabled: !options.hasBuiltinTools() + disabled: !options.hasCwdTools() }, { name: 'model', diff --git a/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts b/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts index f3bc5f7320..4860f16fb2 100644 --- a/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts +++ b/tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts @@ -24,7 +24,7 @@ export interface UseChatFormPickersOptions { /** Gates `/prompt`. */ hasPrompts: () => boolean; /** Gates `/cwd`. */ - hasBuiltinTools: () => boolean; + hasCwdTools: () => boolean; getCwd: () => string | null; /** Mention search fallback scope. */ getServerHome: () => string | null; @@ -63,7 +63,7 @@ export function useChatFormPickers(opts: UseChatFormPickersOptions) { getChatCommands({ showModelSelector: opts.getShowModelSelector(), hasPrompts: opts.hasPrompts, - hasBuiltinTools: opts.hasBuiltinTools + hasCwdTools: opts.hasCwdTools }) ); diff --git a/tools/ui/src/lib/stores/tools.svelte.ts b/tools/ui/src/lib/stores/tools.svelte.ts index 5136101e75..4114ef7566 100644 --- a/tools/ui/src/lib/stores/tools.svelte.ts +++ b/tools/ui/src/lib/stores/tools.svelte.ts @@ -27,6 +27,9 @@ class ToolsStore { private _loading = $state(false); private _error = $state(null); private _disabledTools = $state(new SvelteSet()); + // builtin tools that resolve their paths against the working directory, + // as declared by the server in its `/tools` listing + private _cwdAwareTools = $state(new SvelteSet()); private _toolsEndpointUnreachable = $state(false); private _serverHome = $state(undefined); @@ -476,6 +479,21 @@ class ToolsStore { return this.getEnabledToolsForLLM().length > 0; } + /** + * Check if a working directory is worth setting: at least one builtin tool + * that reads it is both served and left enabled by the user. + */ + get hasEnabledCwdTools(): boolean { + return this._builtinTools.some((def) => { + const name = def.function.name; + + return ( + this._cwdAwareTools.has(name) && + !this._disabledTools.has(this.toolKey(ToolSource.BUILTIN, name)) + ); + }); + } + async fetchBuiltinTools(): Promise { if (this._loading) return; @@ -486,6 +504,9 @@ class ToolsStore { try { const toolInfos = await ToolsService.list(); this._builtinTools = toolInfos.map((info) => info.definition); + this._cwdAwareTools = new SvelteSet( + toolInfos.filter((info) => info.uses_cwd).map((info) => info.tool) + ); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); this._error = errorMessage; diff --git a/tools/ui/src/lib/types/mcp.d.ts b/tools/ui/src/lib/types/mcp.d.ts index b567c20c94..2b5937913d 100644 --- a/tools/ui/src/lib/types/mcp.d.ts +++ b/tools/ui/src/lib/types/mcp.d.ts @@ -292,6 +292,7 @@ export interface ServerBuiltinToolInfo { permissions: { write: boolean; }; + uses_cwd: boolean; definition: OpenAIToolDefinition; } diff --git a/tools/ui/tests/client/components/ChatFormPickersHarness.svelte b/tools/ui/tests/client/components/ChatFormPickersHarness.svelte index 76b2e9fe3d..8e5e56c20b 100644 --- a/tools/ui/tests/client/components/ChatFormPickersHarness.svelte +++ b/tools/ui/tests/client/components/ChatFormPickersHarness.svelte @@ -21,7 +21,7 @@ focusInput: () => {}, getShowModelSelector: () => true, hasPrompts: () => true, - hasBuiltinTools: () => true, + hasCwdTools: () => true, getCwd: () => null, getServerHome: () => null, openModelSelector: () => { From 687e7789271ec1276e3470f158428e11a4f80b6f Mon Sep 17 00:00:00 2001 From: Rafail Giavrimis <47496212+grafail@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:32:37 +0100 Subject: [PATCH 060/210] CUDA: fuse rms_norm + mul + rope (+ view + set_rows) (#26767) * CUDA: fuse rms_norm + mul + rope (+ view + set_rows) * tests: add broadcast weight case to rms_norm_mul_rope * CUDA: check memory ranges before rms_norm rope fusion * CUDA: check memory ranges in rope set_rows fusion --- ggml/src/ggml-cuda/ggml-cuda.cu | 89 +++++++++++- ggml/src/ggml-cuda/rope.cu | 235 ++++++++++++++++++++++++++++++++ ggml/src/ggml-cuda/rope.cuh | 2 + tests/test-backend-ops.cpp | 33 +++-- 4 files changed, 344 insertions(+), 15 deletions(-) diff --git a/ggml/src/ggml-cuda/ggml-cuda.cu b/ggml/src/ggml-cuda/ggml-cuda.cu index 5446b31318..dec6193240 100644 --- a/ggml/src/ggml-cuda/ggml-cuda.cu +++ b/ggml/src/ggml-cuda/ggml-cuda.cu @@ -2651,6 +2651,52 @@ static bool ggml_cuda_should_fuse_rope_set_rows(const ggml_tensor * rope, return true; } +static bool ggml_cuda_should_fuse_rms_norm_mul_rope(const ggml_tensor * rms_norm, + const ggml_tensor * mul, + const ggml_tensor * rope) { + if (rms_norm->op != GGML_OP_RMS_NORM || mul->op != GGML_OP_MUL || rope->op != GGML_OP_ROPE) { + return false; + } + + if (rms_norm->src[0]->type != GGML_TYPE_F32 || rms_norm->type != GGML_TYPE_F32 || + mul->src[0]->type != GGML_TYPE_F32 || mul->src[1]->type != GGML_TYPE_F32 || + mul->type != GGML_TYPE_F32 || rope->type != GGML_TYPE_F32) { + return false; + } + + if (rope->src[0] != mul) { + return false; + } + + //if rms norm is the B operand, then we don't handle broadcast + if (rms_norm == mul->src[1] && !ggml_are_same_shape(mul->src[0], rms_norm)) { + return false; + } + + if (!ggml_are_same_shape(rms_norm, mul)) { + return false; + } + + //rms_norm kernel assumes contiguous rows + if (!ggml_is_contiguous_rows(rms_norm->src[0]) || + !ggml_is_contiguous_rows(mul->src[0]) || !ggml_is_contiguous_rows(mul->src[1])) { + return false; + } + + // the fused kernel handles the norm/neox rope modes only + const int mode = ((const int32_t *) rope->op_params)[2]; + if (mode != GGML_ROPE_TYPE_NORMAL && mode != GGML_ROPE_TYPE_NEOX) { + return false; + } + + const int n_dims = ((const int32_t *) rope->op_params)[1]; + if (n_dims % 2 != 0 || rope->src[0]->ne[0] % 2 != 0) { + return false; + } + + return true; +} + // match gated_delta_net + the strided cpy that scatters its state snapshots into the cache // (slot i -> rollback group i, slot 0 newest), so the kernel can write them and skip the cpy. static int ggml_cuda_try_gdn_cache_fusion( @@ -2980,6 +3026,36 @@ static bool ggml_cuda_can_fuse(const struct ggml_cgraph * cgraph, } } + std::initializer_list rms_norm_mul_rope_ops = { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE }; + std::initializer_list rms_norm_mul_rope_set_rows_ops = { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE, GGML_OP_VIEW, GGML_OP_SET_ROWS }; + + if (is_equal(rms_norm_mul_rope_set_rows_ops, ops) && ggml_can_fuse_subgraph(cgraph, node_idx, ops, { node_idx + 4 })) { + const ggml_tensor * rms_norm = cgraph->nodes[node_idx]; + const ggml_tensor * mul = cgraph->nodes[node_idx + 1]; + const ggml_tensor * rope = cgraph->nodes[node_idx + 2]; + const ggml_tensor * view = cgraph->nodes[node_idx + 3]; + const ggml_tensor * set_rows = cgraph->nodes[node_idx + 4]; + + if (ggml_check_edges(cgraph, node_idx, {{1, 0, 0}, {2, 0, 1}, {3, 0, 2}, {4, 0, 3}}) && + ggml_cuda_should_fuse_rms_norm_mul_rope(rms_norm, mul, rope) && + ggml_cuda_should_fuse_rope_set_rows(rope, view, set_rows)) { + int out_nodes[] = { node_idx + 4 }; + return ggml_cuda_check_fusion_memory_ranges(cgraph, node_idx, (int)ops.size(), out_nodes, 1); + } + } + + if (is_equal(rms_norm_mul_rope_ops, ops) && ggml_can_fuse(cgraph, node_idx, ops)) { + const ggml_tensor * rms_norm = cgraph->nodes[node_idx]; + const ggml_tensor * mul = cgraph->nodes[node_idx + 1]; + const ggml_tensor * rope = cgraph->nodes[node_idx + 2]; + + if (ggml_cuda_should_fuse_rms_norm_mul_rope(rms_norm, mul, rope)) { + int out_nodes[] = { node_idx + 2 }; + return ggml_cuda_check_fusion_memory_ranges(cgraph, node_idx, (int)ops.size(), out_nodes, 1); + } + return false; + } + std::initializer_list rope_set_rows_ops = { GGML_OP_ROPE, GGML_OP_VIEW, GGML_OP_SET_ROWS }; if (is_equal(rope_set_rows_ops, ops) && ggml_can_fuse_subgraph(cgraph, node_idx, ops, { node_idx + 2 })) { @@ -2988,7 +3064,8 @@ static bool ggml_cuda_can_fuse(const struct ggml_cgraph * cgraph, const ggml_tensor * set_rows = cgraph->nodes[node_idx + 2]; if (ggml_cuda_should_fuse_rope_set_rows(rope, view, set_rows)) { - return true; + int out_nodes[] = { node_idx + 2 }; + return ggml_cuda_check_fusion_memory_ranges(cgraph, node_idx, (int)ops.size(), out_nodes, 1); } } @@ -3840,6 +3917,16 @@ static int ggml_cuda_try_fuse(ggml_backend_cuda_context * cuda_ctx, ggml_cgraph return fused_node_count - 1; } + if (ggml_cuda_can_fuse(cgraph, i, { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE, GGML_OP_VIEW, GGML_OP_SET_ROWS }, {})) { + ggml_cuda_op_rms_norm_mul_rope_fused(*cuda_ctx, node, cgraph->nodes[i + 1], cgraph->nodes[i + 2], cgraph->nodes[i + 4]); + return 4; + } + + if (ggml_cuda_can_fuse(cgraph, i, { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE }, {})) { + ggml_cuda_op_rms_norm_mul_rope_fused(*cuda_ctx, node, cgraph->nodes[i + 1], cgraph->nodes[i + 2], nullptr); + return 2; + } + if (ggml_cuda_can_fuse(cgraph, i, { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ADD }, {})) { ggml_cuda_op_rms_norm_fused_add(*cuda_ctx, node, cgraph->nodes[i + 1], cgraph->nodes[i + 2]); return 2; diff --git a/ggml/src/ggml-cuda/rope.cu b/ggml/src/ggml-cuda/rope.cu index e20a5cb6be..504c6b818d 100644 --- a/ggml/src/ggml-cuda/rope.cu +++ b/ggml/src/ggml-cuda/rope.cu @@ -670,3 +670,238 @@ void ggml_cuda_op_rope_back(ggml_backend_cuda_context & ctx, ggml_tensor * dst) void ggml_cuda_op_rope_fused(ggml_backend_cuda_context & ctx, ggml_tensor * rope, ggml_tensor * set_rows) { ggml_cuda_op_rope_impl(ctx, rope, set_rows); } + +// fused RMS_NORM + MUL + ROPE (+ VIEW + SET_ROWS) +// one block per row: block_reduce gives the norm scale, then each thread applies mul and rope to the elements it owns +template +static __global__ void rms_norm_mul_rope_f32( + const float * x, D * dst, const int ncols, + const int64_t s01, const int64_t s02, const int64_t s03, + const int64_t s1, const int64_t s2, const int64_t s3, + const float eps, + const float * mul, + const int64_t mul_s01, const int64_t mul_s02, const int64_t mul_s03, + const uint3 mul_ncols_packed, const uint3 mul_nrows_packed, + const uint3 mul_nchannels_packed, const uint3 mul_nsamples_packed, + const int n_dims, const int32_t * pos, + const float freq_scale, const float ext_factor, const float attn_factor, + const rope_corr_dims corr_dims, const float theta_scale, + const float * freq_factors, + const int64_t * row_indices, const int set_rows_stride, + const bool is_neox) { + ggml_cuda_pdl_lc(); + const int row = blockIdx.x; + const int channel = blockIdx.y; + const int sample = blockIdx.z; + const int tid = threadIdx.x; + + x += sample*s03 + channel*s02 + row*s01; + + const uint32_t mul_row = fastmodulo(row, mul_nrows_packed); + const uint32_t mul_channel = fastmodulo(channel, mul_nchannels_packed); + const uint32_t mul_sample = fastmodulo(sample, mul_nsamples_packed); + mul += mul_sample*mul_s03 + mul_channel*mul_s02 + mul_row*mul_s01; + + float tmp = 0.0f; + + ggml_cuda_pdl_sync(); + for (int col = tid; col < ncols; col += block_size) { + const float xi = x[col]; + tmp += xi * xi; + } + + extern __shared__ float s_sum[]; + tmp = block_reduce(tmp, s_sum); + + const float scale = rsqrtf(tmp/ncols + eps); + + int64_t idst = sample*s3 + channel*s2 + row*s1; + if (set_rows_stride != 0) { + idst = row*s1 + row_indices[channel]*set_rows_stride; + } + dst += idst; + + for (int i0 = 2*tid; i0 < ncols; i0 += 2*block_size) { + int ix0; + int ix1; + if (is_neox && i0 < n_dims) { + ix0 = i0/2; + ix1 = i0/2 + n_dims/2; + } else { + ix0 = i0 + 0; + ix1 = i0 + 1; + } + + const float x0 = scale * x[ix0] * mul[fastmodulo(ix0, mul_ncols_packed)]; + const float x1 = scale * x[ix1] * mul[fastmodulo(ix1, mul_ncols_packed)]; + + if (i0 >= n_dims) { + dst[ix0] = ggml_cuda_cast(x0); + dst[ix1] = ggml_cuda_cast(x1); + continue; + } + + const float theta_base = pos[channel]*powf(theta_scale, i0/2.0f); + const float freq_factor = has_ff ? freq_factors[i0/2] : 1.0f; + + float cos_theta; + float sin_theta; + rope_yarn(theta_base/freq_factor, freq_scale, corr_dims, i0, ext_factor, attn_factor, cos_theta, sin_theta); + + dst[ix0] = ggml_cuda_cast(x0*cos_theta - x1*sin_theta); + dst[ix1] = ggml_cuda_cast(x0*sin_theta + x1*cos_theta); + } +} + +template +static void rms_norm_mul_rope_cuda( + const float * x, D * dst, + const int ncols, const int nrows, const int nchannels, const int nsamples, + const int64_t s01, const int64_t s02, const int64_t s03, + const int64_t s1, const int64_t s2, const int64_t s3, + const float eps, + const float * mul, + const int64_t mul_s01, const int64_t mul_s02, const int64_t mul_s03, + const uint32_t mul_ncols, const uint32_t mul_nrows, + const uint32_t mul_nchannels, const uint32_t mul_nsamples, + const int n_dims, const int32_t * pos, + const float freq_scale, const float freq_base, const float ext_factor, const float attn_factor, + const rope_corr_dims corr_dims, + const float * freq_factors, + const int64_t * row_indices, const int set_rows_stride, + const bool is_neox, cudaStream_t stream) { + GGML_ASSERT(ncols % 2 == 0); + + const dim3 blocks_num(nrows, nchannels, nsamples); + + const float theta_scale = powf(freq_base, -2.0f/n_dims); + + const uint3 mul_ncols_packed = init_fastdiv_values(mul_ncols); + const uint3 mul_nrows_packed = init_fastdiv_values(mul_nrows); + const uint3 mul_nchannels_packed = init_fastdiv_values(mul_nchannels); + const uint3 mul_nsamples_packed = init_fastdiv_values(mul_nsamples); + + if (ncols < 1024) { + const dim3 block_dims(256, 1, 1); + const ggml_cuda_kernel_launch_params launch_params = {blocks_num, block_dims, 32*sizeof(float), stream}; + if (freq_factors == nullptr) { + ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<256, false, D>, launch_params, + x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03, + mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed, + n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale, + freq_factors, row_indices, set_rows_stride, is_neox); + } else { + ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<256, true, D>, launch_params, + x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03, + mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed, + n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale, + freq_factors, row_indices, set_rows_stride, is_neox); + } + } else { + const dim3 block_dims(1024, 1, 1); + const ggml_cuda_kernel_launch_params launch_params = {blocks_num, block_dims, 32*sizeof(float), stream}; + if (freq_factors == nullptr) { + ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<1024, false, D>, launch_params, + x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03, + mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed, + n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale, + freq_factors, row_indices, set_rows_stride, is_neox); + } else { + ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<1024, true, D>, launch_params, + x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03, + mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed, + n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale, + freq_factors, row_indices, set_rows_stride, is_neox); + } + } +} + +void ggml_cuda_op_rms_norm_mul_rope_fused(ggml_backend_cuda_context & ctx, + ggml_tensor * rms_norm, ggml_tensor * mul, ggml_tensor * rope, ggml_tensor * set_rows) { + const ggml_tensor * x = rms_norm->src[0]; + const ggml_tensor * mul_src = mul->src[0] == rms_norm ? mul->src[1] : mul->src[0]; + + float eps = 0.0f; + memcpy(&eps, rms_norm->op_params, sizeof(float)); + GGML_ASSERT(eps >= 0.0f); + + GGML_ASSERT(x->type == GGML_TYPE_F32); + GGML_ASSERT(mul_src->type == GGML_TYPE_F32); + GGML_ASSERT(rope->type == GGML_TYPE_F32); + + void * dst_d = rope->data; + ggml_type dst_type = rope->type; + const int64_t * row_indices = nullptr; + int set_rows_stride = 0; + + if (set_rows != nullptr) { + dst_d = set_rows->data; + dst_type = set_rows->type; + row_indices = (const int64_t *) set_rows->src[1]->data; + set_rows_stride = set_rows->nb[1] / ggml_type_size(set_rows->type); + } + + const int n_dims = ((const int32_t *) rope->op_params)[1]; + const int mode = ((const int32_t *) rope->op_params)[2]; + const int n_ctx_orig = ((const int32_t *) rope->op_params)[4]; + + float freq_base; + float freq_scale; + float ext_factor; + float attn_factor; + float beta_fast; + float beta_slow; + + memcpy(&freq_base, (const int32_t *) rope->op_params + 5, sizeof(float)); + memcpy(&freq_scale, (const int32_t *) rope->op_params + 6, sizeof(float)); + memcpy(&ext_factor, (const int32_t *) rope->op_params + 7, sizeof(float)); + memcpy(&attn_factor, (const int32_t *) rope->op_params + 8, sizeof(float)); + memcpy(&beta_fast, (const int32_t *) rope->op_params + 9, sizeof(float)); + memcpy(&beta_slow, (const int32_t *) rope->op_params + 10, sizeof(float)); + + const bool is_neox = mode & GGML_ROPE_TYPE_NEOX; + + const int32_t * pos = (const int32_t *) rope->src[1]->data; + + const float * freq_factors = rope->src[2] != nullptr ? (const float *) rope->src[2]->data : nullptr; + + rope_corr_dims corr_dims; + ggml_rope_yarn_corr_dims(n_dims, n_ctx_orig, freq_base, beta_fast, beta_slow, corr_dims.v); + + const size_t ts0 = ggml_type_size(x->type); + GGML_ASSERT(x->nb[0] == ts0); + const int64_t s01 = x->nb[1] / ts0; + const int64_t s02 = x->nb[2] / ts0; + const int64_t s03 = x->nb[3] / ts0; + + const size_t ts_mul = ggml_type_size(mul_src->type); + GGML_ASSERT(mul_src->nb[0] == ts_mul); + const int64_t mul_s01 = mul_src->nb[1] / ts_mul; + const int64_t mul_s02 = mul_src->nb[2] / ts_mul; + const int64_t mul_s03 = mul_src->nb[3] / ts_mul; + + const size_t ts_dst = ggml_type_size(rope->type); + const int64_t s1 = rope->nb[1] / ts_dst; + const int64_t s2 = rope->nb[2] / ts_dst; + const int64_t s3 = rope->nb[3] / ts_dst; + + cudaStream_t stream = ctx.stream(); + + if (dst_type == GGML_TYPE_F32) { + rms_norm_mul_rope_cuda((const float *) x->data, (float *) dst_d, + x->ne[0], x->ne[1], x->ne[2], x->ne[3], s01, s02, s03, s1, s2, s3, eps, + (const float *) mul_src->data, mul_s01, mul_s02, mul_s03, + mul_src->ne[0], mul_src->ne[1], mul_src->ne[2], mul_src->ne[3], + n_dims, pos, freq_scale, freq_base, ext_factor, attn_factor, corr_dims, + freq_factors, row_indices, set_rows_stride, is_neox, stream); + } else if (dst_type == GGML_TYPE_F16) { + rms_norm_mul_rope_cuda((const float *) x->data, (half *) dst_d, + x->ne[0], x->ne[1], x->ne[2], x->ne[3], s01, s02, s03, s1, s2, s3, eps, + (const float *) mul_src->data, mul_s01, mul_s02, mul_s03, + mul_src->ne[0], mul_src->ne[1], mul_src->ne[2], mul_src->ne[3], + n_dims, pos, freq_scale, freq_base, ext_factor, attn_factor, corr_dims, + freq_factors, row_indices, set_rows_stride, is_neox, stream); + } else { + GGML_ABORT("fatal error"); + } +} diff --git a/ggml/src/ggml-cuda/rope.cuh b/ggml/src/ggml-cuda/rope.cuh index 72af086cd1..7ce2d71c50 100644 --- a/ggml/src/ggml-cuda/rope.cuh +++ b/ggml/src/ggml-cuda/rope.cuh @@ -7,3 +7,5 @@ void ggml_cuda_op_rope(ggml_backend_cuda_context & ctx, ggml_tensor * dst); void ggml_cuda_op_rope_back(ggml_backend_cuda_context & ctx, ggml_tensor * dst); void ggml_cuda_op_rope_fused(ggml_backend_cuda_context & ctx, ggml_tensor * dst, ggml_tensor * set_rows); + +void ggml_cuda_op_rms_norm_mul_rope_fused(ggml_backend_cuda_context & ctx, ggml_tensor * rms_norm, ggml_tensor * mul, ggml_tensor * rope, ggml_tensor * set_rows); diff --git a/tests/test-backend-ops.cpp b/tests/test-backend-ops.cpp index 6688debd4a..14a2340604 100644 --- a/tests/test-backend-ops.cpp +++ b/tests/test-backend-ops.cpp @@ -2584,6 +2584,7 @@ struct test_rms_norm_mul_rope : public test_case { const float eps; const bool multi_add; // test a sequence of adds feeding into rms_norm const bool set_rows; + const bool broadcast; // multiply by a 1D [ne0] weight, as model norm weights are int mode; std::string op_desc(ggml_tensor * t) override { @@ -2594,12 +2595,12 @@ struct test_rms_norm_mul_rope : public test_case { bool run_whole_graph() override { return true; } std::string vars() override { - return VARS_TO_STR5(ne, eps, multi_add, set_rows, mode); + return VARS_TO_STR6(ne, eps, multi_add, set_rows, broadcast, mode); } test_rms_norm_mul_rope(std::array ne, float eps = 1e-6f, bool multi_add = false, - bool set_rows = false, int mode = GGML_ROPE_TYPE_NORMAL) - : ne(ne), eps(eps), multi_add(multi_add), set_rows(set_rows), mode(mode) {} + bool set_rows = false, bool broadcast = false, int mode = GGML_ROPE_TYPE_NORMAL) + : ne(ne), eps(eps), multi_add(multi_add), set_rows(set_rows), broadcast(broadcast), mode(mode) {} ggml_tensor * build_graph(ggml_context * ctx) override { ggml_tensor * a = ggml_new_tensor_4d(ctx, GGML_TYPE_F32, ne[0], ne[1], ne[2], 1); @@ -2610,7 +2611,9 @@ struct test_rms_norm_mul_rope : public test_case { a = ggml_add(ctx, ggml_add(ctx, a, b), c); } - a = ggml_mul(ctx, ggml_rms_norm(ctx, a, eps), b); + ggml_tensor * w = broadcast ? ggml_new_tensor_1d(ctx, GGML_TYPE_F32, ne[0]) : b; + + a = ggml_mul(ctx, ggml_rms_norm(ctx, a, eps), w); ggml_tensor * pos = ggml_new_tensor_1d(ctx, GGML_TYPE_I32, ne[2]); @@ -8756,16 +8759,18 @@ static std::vector> make_test_cases_eval() { for (auto multi_add : {false, true}) { for (auto set_rows : {false, true}) { - for (auto rope : {GGML_ROPE_TYPE_NORMAL, GGML_ROPE_TYPE_NEOX}) { - test_cases.emplace_back(new test_rms_norm_mul_rope({768, 1, 1, 1}, 1e-6f, multi_add, set_rows, rope)); - test_cases.emplace_back(new test_rms_norm_mul_rope({768, 3, 1, 1}, 1e-6f, multi_add, set_rows, rope)); - test_cases.emplace_back(new test_rms_norm_mul_rope({768, 3, 5, 1}, 1e-6f, multi_add, set_rows, rope)); - test_cases.emplace_back(new test_rms_norm_mul_rope({128, 32, 2, 1}, 1e-6f, multi_add, set_rows, rope)); - test_cases.emplace_back(new test_rms_norm_mul_rope({128, 4, 2, 1}, 1e-6f, multi_add, set_rows, rope)); - test_cases.emplace_back(new test_rms_norm_mul_rope({128, 32, 50, 1}, 1e-6f, multi_add, set_rows, rope)); - test_cases.emplace_back(new test_rms_norm_mul_rope({128, 4, 50, 1}, 1e-6f, multi_add, set_rows, rope)); - test_cases.emplace_back(new test_rms_norm_mul_rope({8192, 2, 2, 1}, 1e-6f, multi_add, set_rows, rope)); - test_cases.emplace_back(new test_rms_norm_mul_rope({8192, 2, 2, 1}, 1e-6f, multi_add, set_rows, rope)); + for (auto broadcast : {false, true}) { + for (auto rope : {GGML_ROPE_TYPE_NORMAL, GGML_ROPE_TYPE_NEOX}) { + test_cases.emplace_back(new test_rms_norm_mul_rope({768, 1, 1, 1}, 1e-6f, multi_add, set_rows, broadcast, rope)); + test_cases.emplace_back(new test_rms_norm_mul_rope({768, 3, 1, 1}, 1e-6f, multi_add, set_rows, broadcast, rope)); + test_cases.emplace_back(new test_rms_norm_mul_rope({768, 3, 5, 1}, 1e-6f, multi_add, set_rows, broadcast, rope)); + test_cases.emplace_back(new test_rms_norm_mul_rope({128, 32, 2, 1}, 1e-6f, multi_add, set_rows, broadcast, rope)); + test_cases.emplace_back(new test_rms_norm_mul_rope({128, 4, 2, 1}, 1e-6f, multi_add, set_rows, broadcast, rope)); + test_cases.emplace_back(new test_rms_norm_mul_rope({128, 32, 50, 1}, 1e-6f, multi_add, set_rows, broadcast, rope)); + test_cases.emplace_back(new test_rms_norm_mul_rope({128, 4, 50, 1}, 1e-6f, multi_add, set_rows, broadcast, rope)); + test_cases.emplace_back(new test_rms_norm_mul_rope({8192, 2, 2, 1}, 1e-6f, multi_add, set_rows, broadcast, rope)); + test_cases.emplace_back(new test_rms_norm_mul_rope({8192, 2, 2, 1}, 1e-6f, multi_add, set_rows, broadcast, rope)); + } } } } From 7ba604f1cb61cd14898138e9abc0b4ff2601f180 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 9 Aug 2026 00:42:50 +0200 Subject: [PATCH 061/210] server: report the isolate working directory from get_info (#26773) * server: report the isolate working directory from get_info Without an explicit cwd, get_info fell back to the server process working directory even when a tools runtime was configured. That named a host path no tool would ever run in, since an isolate starts in a directory of its own. It now asks the isolate for its working directory in that case, and keeps the process one only when the tools run on the host. * remove redundant comment --------- Co-authored-by: Xuan-Son Nguyen --- tools/server/server-tools.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tools/server/server-tools.cpp b/tools/server/server-tools.cpp index d5e696434c..27c663fdd2 100644 --- a/tools/server/server-tools.cpp +++ b/tools/server/server-tools.cpp @@ -1669,8 +1669,13 @@ struct server_tool_get_info : server_tool { std::string cwd = json_value(params, "cwd", std::string()); if (cwd.empty()) { - std::error_code ec; - cwd = path_to_utf8(fs::current_path(ec)); + if (json_value(params, "runtime", std::string()).empty()) { + std::error_code ec; + cwd = path_to_utf8(fs::current_path(ec)); + } else { + auto pwd = io->run({"pwd"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT); + cwd = pwd.exit_code == 0 && !pwd.timed_out ? string_strip(pwd.output) : "unknown"; + } } return { From 61141f1487e63d9b22aec193131253bb2ea0800c Mon Sep 17 00:00:00 2001 From: Aaron Teo Date: Sun, 9 Aug 2026 18:15:28 +0800 Subject: [PATCH 062/210] ci: rm `GGML_HIP_ROCWMMA_FATTN` (#26760) Signed-off-by: Aaron Teo --- .devops/rocm.Dockerfile | 1 - .github/workflows/build-cuda-ubuntu.yml | 1 - .github/workflows/build-cuda-windows.yml | 1 - .github/workflows/release.yml | 2 -- ci/run.sh | 2 +- 5 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.devops/rocm.Dockerfile b/.devops/rocm.Dockerfile index a8bc4e1fcd..20f6ad6360 100644 --- a/.devops/rocm.Dockerfile +++ b/.devops/rocm.Dockerfile @@ -57,7 +57,6 @@ COPY --from=web /app/tools/ui/dist tools/ui/dist RUN HIPCXX="$(hipconfig -l)/clang" HIP_PATH="$(hipconfig -R)" \ cmake -S . -B build \ -DGGML_HIP=ON \ - -DGGML_HIP_ROCWMMA_FATTN=ON \ -DAMDGPU_TARGETS="$ROCM_DOCKER_ARCH" \ -DGGML_BACKEND_DL=ON -DGGML_CPU_ALL_VARIANTS=ON \ -DCMAKE_BUILD_TYPE=Release -DLLAMA_BUILD_TESTS=OFF \ diff --git a/.github/workflows/build-cuda-ubuntu.yml b/.github/workflows/build-cuda-ubuntu.yml index 6271b22cbd..2528b18573 100644 --- a/.github/workflows/build-cuda-ubuntu.yml +++ b/.github/workflows/build-cuda-ubuntu.yml @@ -99,7 +99,6 @@ jobs: run: | cmake -B build -S . \ -DCMAKE_HIP_COMPILER="$(hipconfig -l)/clang" \ - -DGGML_HIP_ROCWMMA_FATTN=ON \ -DGPU_TARGETS="gfx1030" \ -DGGML_HIP=ON cmake --build build --config Release -j $(nproc) diff --git a/.github/workflows/build-cuda-windows.yml b/.github/workflows/build-cuda-windows.yml index e9e941421b..367a3a8546 100644 --- a/.github/workflows/build-cuda-windows.yml +++ b/.github/workflows/build-cuda-windows.yml @@ -150,7 +150,6 @@ jobs: -DLLAMA_BUILD_BORINGSSL=ON ` -DROCM_DIR="${env:HIP_PATH}" ` -DGGML_HIP=ON ` - -DGGML_HIP_ROCWMMA_FATTN=ON ` -DGPU_TARGETS="gfx1100" ` -DGGML_RPC=ON cmake --build build -j ${env:NUMBER_OF_PROCESSORS} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f587ed93c5..968d2d4b7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1229,7 +1229,6 @@ jobs: -DGPU_TARGETS="${{ matrix.gpu_targets }}" \ -DGGML_HIP=ON \ -DHIP_PLATFORM=amd \ - -DGGML_HIP_ROCWMMA_FATTN=ON \ -DHF_UI_VERSION=${{ needs.get-version.outputs.ui_version }} \ ${{ env.CMAKE_ARGS }} cmake --build build --config Release -j $(nproc) @@ -1353,7 +1352,6 @@ jobs: -DGGML_NATIVE=OFF ` -DGGML_CPU=OFF ` -DGPU_TARGETS="${{ matrix.gpu_targets }}" ` - -DGGML_HIP_ROCWMMA_FATTN=ON ` -DGGML_HIP=ON ` -DHF_UI_VERSION=${{ needs.get-version.outputs.ui_version }} ` -DLLAMA_BUILD_BORINGSSL=ON diff --git a/ci/run.sh b/ci/run.sh index 8506bb4089..f6c7eb0d5c 100755 --- a/ci/run.sh +++ b/ci/run.sh @@ -92,7 +92,7 @@ if [ ! -z ${GG_BUILD_CUDA} ]; then fi if [ ! -z ${GG_BUILD_ROCM} ]; then - CMAKE_EXTRA="${CMAKE_EXTRA} -DCMAKE_HIP_COMPILER=$(hipconfig -l)/clang -DGGML_HIP=ON -DGGML_HIP_ROCWMMA_FATTN=ON" + CMAKE_EXTRA="${CMAKE_EXTRA} -DCMAKE_HIP_COMPILER=$(hipconfig -l)/clang -DGGML_HIP=ON" if [ -z ${GG_BUILD_AMDGPU_TARGETS} ]; then echo "Missing GG_BUILD_AMDGPU_TARGETS, please set it to your GPU architecture (e.g. gfx90a, gfx1100, etc.)" exit 1 From 08659901c43b51de735740f1cf61bb82fbe0c4e4 Mon Sep 17 00:00:00 2001 From: Hao-Chen2337 <2113996104@qq.com> Date: Sun, 9 Aug 2026 18:16:53 +0800 Subject: [PATCH 063/210] ggml-cpu : fix missing Q5_0 dispatch in SpaceMiT backend (#26792) --- ggml/src/ggml-cpu/spacemit/ime.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ggml/src/ggml-cpu/spacemit/ime.cpp b/ggml/src/ggml-cpu/spacemit/ime.cpp index 9563ea3e4b..29d683270e 100644 --- a/ggml/src/ggml-cpu/spacemit/ime.cpp +++ b/ggml/src/ggml-cpu/spacemit/ime.cpp @@ -195,6 +195,7 @@ template class tensor_ case GGML_TYPE_Q4_K: case GGML_TYPE_Q6_K: case GGML_TYPE_Q8_0: + case GGML_TYPE_Q5_0: case GGML_TYPE_Q5_1: case GGML_TYPE_Q5_K: //case GGML_TYPE_MXFP4: @@ -214,6 +215,7 @@ template class tensor_ case GGML_TYPE_Q4_K: case GGML_TYPE_Q6_K: case GGML_TYPE_Q8_0: + case GGML_TYPE_Q5_0: case GGML_TYPE_Q5_1: case GGML_TYPE_Q5_K: //case GGML_TYPE_MXFP4: From 936918514ce522b553c0fd80b169a6440e6096c6 Mon Sep 17 00:00:00 2001 From: Xuan-Son Nguyen Date: Sun, 9 Aug 2026 16:51:21 +0200 Subject: [PATCH 064/210] ci: add pr-draft-label (#26801) --- .github/workflows/pr-draft-label.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/pr-draft-label.yml diff --git a/.github/workflows/pr-draft-label.yml b/.github/workflows/pr-draft-label.yml new file mode 100644 index 0000000000..d2594c823d --- /dev/null +++ b/.github/workflows/pr-draft-label.yml @@ -0,0 +1,23 @@ +name: Convert PR to draft + +on: + pull_request_target: + types: [labeled] + +permissions: + pull-requests: write + issues: write + contents: write # required for "gh pr ready" command, see https://github.com/cli/cli/issues/8910 + +jobs: + convert-to-draft: + if: github.event.label.name == 'draft' && github.event.pull_request.draft == false + runs-on: ubuntu-slim + steps: + - name: Convert PR to draft + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ github.event.pull_request.html_url }} + run: | + gh pr ready --undo "$PR_URL" + gh pr edit "$PR_URL" --remove-label draft From 74ce15741b420b8d6f12e720398458b576c51c2c Mon Sep 17 00:00:00 2001 From: Pascal Date: Sun, 9 Aug 2026 21:20:23 +0200 Subject: [PATCH 065/210] ui: degrade the working directory picker when file search is off (#26811) The picker mounts whenever a cwd-aware builtin tool is enabled, so it can open while file_glob_search is not served or was disabled by the user. Every typed query then fired a search that could only fail with a raw error. Gate the debounced search on the tool state, the same way the mention picker does, and show a message in place of the results list that explains why search is unavailable. Manual entry with Enter still commits a directory. The Browse button and the search scope footer are hidden as well: Browse resolves the picked folder name through file_glob_search, and the client-side toggle would not stop that call. --- .../ChatForm/ChatFormWorkingDirectory.svelte | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte index f8069c93e5..108cf1b19c 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte @@ -67,6 +67,20 @@ const pickerSupported = typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function'; + // When the server does not serve file_glob_search or the user disabled + // it, the picker still opens for manual entry but explains why search is + // unavailable instead of firing searches that would only fail. Browse is + // hidden too: it resolves the picked folder name through the same tool. + const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.FILE_GLOB_SEARCH)); + const fileSearchEnabled = $derived( + fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey) + ); + const searchUnavailableMessage = $derived( + fileSearchKey === null + ? 'File search is unavailable on this server - type a full path and press Enter' + : 'File search is disabled - type a full path and press Enter, or enable "Search files" in Settings > Tools' + ); + let searchInputRef: HTMLInputElement | null = $state(null); let queryResults = $state([]); @@ -98,7 +112,7 @@ if (!isOpen) return; const q = query.trim(); nav.reset(-1); - if (q) { + if (q && fileSearchEnabled) { search.run(q); } else { search.cancel(); @@ -123,7 +137,7 @@ // children too, so path navigation does not require a trailing slash. const search = useDebouncedSearch({ debounceMs: SEARCH_DEBOUNCE_MS, - canRun: () => isOpen, + canRun: () => isOpen && fileSearchEnabled, getQuery: () => query.trim(), run: async (q, signal, isCurrent) => { const trimmed = q.trim(); @@ -340,7 +354,9 @@ class="w-full" /> - {#if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)} + {#if !fileSearchEnabled} +
{searchUnavailableMessage}
+ {:else if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)} {/if} - {#if pickerSupported} + {#if pickerSupported && fileSearchEnabled}