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