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:
Aleksander Grygier
2026-08-04 19:05:48 +02:00
committed by GitHub
parent 0713275082
commit 2f56fc3431
41 changed files with 1946 additions and 95 deletions

View File

@@ -10,8 +10,10 @@
#include <ctime>
#include <atomic>
#include <cstring>
#include <cstdlib>
#include <algorithm>
#include <unordered_set>
#include <tuple>
#include <functional>
#include <memory>
@@ -34,7 +36,40 @@ json server_tool::to_json() const {
}
static constexpr size_t SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT = 8 * 1024 * 1024; // 8 MB
static constexpr int SERVER_TOOL_GIT_LS_FILES_TIMEOUT = 15; // seconds
// budget for one listing call, shared by the git and walker paths
static constexpr int SERVER_TOOL_LIST_ENTRIES_TIMEOUT = 15; // seconds
// entry kinds a directory listing may return
enum class list_kind {
files, // regular files only
dirs, // directories only
all, // both
};
// home directory, read once at first use (getenv is not thread safe against setenv)
static const std::string & home_dir() {
static const std::string home = [] {
const char * h = getenv("HOME");
#ifdef _WIN32
if (h == nullptr) h = getenv("USERPROFILE");
#endif
return h ? std::string(h) : std::string();
}();
return home;
}
static std::string expand_home(const std::string & path) {
if (path.empty() || path[0] != '~') return path;
if (path.size() > 1 && path[1] != '/' && path[1] != '\\') return path;
const std::string & home = home_dir();
if (home.empty()) return path;
return home + path.substr(1);
}
// depth of a '/'-separated relative path: "a/b/c" is 3
static int entry_depth(const std::string & rel) {
return 1 + (int) std::count(rel.begin(), rel.end(), '/');
}
class tools_io {
public:
@@ -51,8 +86,17 @@ public:
virtual bool file_size(const std::string & path, uintmax_t & out_size) const = 0;
virtual bool read_file(const std::string & path, std::string & out) const = 0;
virtual bool write_file(const std::string & path, const std::string & content) const = 0;
// paths relative to `base`, '/'-separated; sets `err` if `base` isn't a directory
virtual std::vector<std::string> list_files(const std::string & base, std::string & err) const = 0;
// resolve `path` against the IO's working directory; absolute paths are returned unchanged
virtual std::string resolve(const std::string & path) const = 0;
struct list_entry {
std::string rel; // '/'-separated, relative to `base`
bool is_dir = false;
};
// entries relative to `base`; sets `err` if `base` isn't a directory
// max_depth == 0 means unlimited, 1 means direct children of `base` only
// `base` must already be resolved (absolute); `caller_path` is the path the
// caller passed, used only for error messages
virtual std::vector<list_entry> list_entries(const std::string & base, const std::string & caller_path, int max_depth, list_kind kind, std::string & err, bool & truncated) const = 0;
// on_chunk, if set, is called with each chunk of output as it is read (before truncation cuts in);
// returning false terminates the process early (e.g. the client disconnected)
virtual exec_result run(
@@ -67,6 +111,22 @@ public:
// cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
explicit tools_io_basic(std::string cwd = "") : cwd(std::move(cwd)) {}
// expands a leading `~`, then resolves `path` against `cwd` (or the server
// working directory when `cwd` is unset); the result is always absolute
std::string resolve(const std::string & path) const override {
std::string p = expand_home(path);
if (fs::path(p).is_absolute()) {
return p;
}
if (cwd.empty()) {
std::error_code ec;
fs::path cur = fs::current_path(ec);
if (ec) return p;
return (cur / p).string();
}
return (fs::path(cwd) / p).string();
}
bool is_directory(const std::string & path) const override {
std::error_code ec;
return fs::is_directory(resolve(path), ec) && !ec;
@@ -105,34 +165,41 @@ public:
return (bool) f;
}
std::vector<std::string> list_files(const std::string & base, std::string & err) const override {
std::vector<list_entry> list_entries(const std::string & base, const std::string & caller_path, int max_depth, list_kind kind, std::string & err, bool & truncated) const override {
err.clear();
std::string abs_base = resolve(base);
if (!is_directory(base)) {
err = "path does not exist or is not a directory: " + base;
truncated = false;
std::error_code ec;
if (!fs::is_directory(base, ec) || ec) {
err = "path does not exist or is not a directory: " + caller_path;
return {};
}
auto res = run(
{"git", "-C", abs_base, "ls-files", "--cached", "--others", "--exclude-standard"},
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_GIT_LS_FILES_TIMEOUT);
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(SERVER_TOOL_LIST_ENTRIES_TIMEOUT);
if (res.exit_code == 0 && !res.timed_out) {
std::vector<std::string> result;
std::istringstream iss(res.output);
std::string line;
while (std::getline(iss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.empty()) continue;
std::replace(line.begin(), line.end(), '\\', '/');
if (is_regular_file((fs::path(base) / line).string())) {
result.push_back(line);
// git ls-files cannot list directories; use the walker when they are requested
if (kind == list_kind::files) {
auto res = run(
{"git", "-C", base, "ls-files", "--cached", "--others", "--exclude-standard"},
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, SERVER_TOOL_LIST_ENTRIES_TIMEOUT);
if (res.exit_code == 0 && !res.timed_out) {
std::vector<list_entry> result;
std::istringstream iss(res.output);
std::string line;
while (std::getline(iss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.empty()) continue;
std::replace(line.begin(), line.end(), '\\', '/');
if (max_depth > 0 && entry_depth(line) > max_depth) continue;
if (is_regular_file((fs::path(base) / line).string())) {
result.push_back({line, false});
}
}
return result;
}
return result;
}
return list_files_fallback(abs_base);
return list_entries_fallback(base, max_depth, kind, deadline, truncated);
}
exec_result run(
@@ -211,14 +278,6 @@ public:
private:
std::string cwd;
// resolves `path` against `cwd` if `path` is relative and `cwd` is set; otherwise returns `path` unchanged
std::string resolve(const std::string & path) const {
if (cwd.empty() || fs::path(path).is_absolute()) {
return path;
}
return (fs::path(cwd) / path).string();
}
static const std::unordered_set<std::string> & junk_dir_names() {
static const std::unordered_set<std::string> names = {
".git", ".svn", ".hg", "node_modules", "__pycache__",
@@ -227,28 +286,50 @@ private:
return names;
}
std::vector<std::string> list_files_fallback(const std::string & base) const {
std::vector<std::string> result;
std::vector<list_entry> list_entries_fallback(const std::string & base, int max_depth, list_kind kind,
std::chrono::steady_clock::time_point deadline, bool & truncated) const {
std::vector<list_entry> result;
std::error_code ec;
std::vector<std::pair<fs::path, fs::path>> stack;
stack.emplace_back(fs::path(base), fs::path());
std::vector<std::tuple<fs::path, fs::path, int>> stack;
stack.emplace_back(fs::path(base), fs::path(), 0);
while (!stack.empty()) {
auto [dir, rel_dir] = stack.back();
auto [dir, rel_dir, depth] = stack.back();
stack.pop_back();
for (const auto & entry : fs::directory_iterator(dir, fs::directory_options::skip_permission_denied, ec)) {
// the throwing increment would escape the tool on a directory that
// goes away mid walk, so step the iterator explicitly
fs::directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec);
for (const fs::directory_iterator end; it != end; it.increment(ec)) {
if (ec) break;
if (std::chrono::steady_clock::now() >= deadline) {
truncated = true;
return result;
}
const fs::directory_entry & entry = *it;
std::string fname = entry.path().filename().string();
std::error_code tec;
if (entry.is_directory(tec)) {
std::string rel = (rel_dir / fname).string();
std::replace(rel.begin(), rel.end(), '\\', '/');
if (kind == list_kind::dirs || kind == list_kind::all) {
result.push_back({rel, true});
}
// junk directories stay selectable but are never walked: they
// hold nothing worth searching and can be enormous
if (junk_dir_names().count(fname) > 0) continue;
stack.emplace_back(entry.path(), rel_dir / fname);
// do not descend into symlinks: a link can point back to an
// ancestor and loop forever
if (!entry.is_symlink(tec) && (max_depth == 0 || depth + 1 < max_depth)) {
stack.emplace_back(entry.path(), rel_dir / fname, depth + 1);
}
} else if (entry.is_regular_file(tec)) {
std::string rel = (rel_dir / fname).string();
std::replace(rel.begin(), rel.end(), '\\', '/');
result.push_back(rel);
if (kind == list_kind::files || kind == list_kind::all) {
result.push_back({rel, false});
}
}
}
}
@@ -363,6 +444,9 @@ struct server_tool_read_file : server_tool {
//
static constexpr size_t SERVER_TOOL_FILE_SEARCH_MAX_RESULTS = 100;
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_FILE = "file";
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_DIR = "dir";
static constexpr const char * SERVER_TOOL_FILE_SEARCH_TYPE_ALL = "all";
struct server_tool_file_glob_search : server_tool {
server_tool_file_glob_search() {
@@ -382,13 +466,18 @@ struct server_tool_file_glob_search : server_tool {
"and common junk directories (.git, node_modules, build, dist, etc.) otherwise. "
"A pattern with no '/' (e.g. \"*.cpp\") matches the file's basename at any depth. "
"A pattern containing '/' matches the full relative path; unless already anchored with "
"\"**/\" or a leading '/', it is automatically prefixed with \"**/\"."},
"\"**/\" or a leading '/', it is automatically prefixed with \"**/\". "
"Use type=\"dir\" or \"all\" to also list directories; directory entries are suffixed with '/' in the output. "
"Note: directory listings do not apply .gitignore filtering."},
{"parameters", {
{"type", "object"},
{"properties", {
{"path", {{"type", "string"}, {"description", "Base directory to search in"}}},
{"include", {{"type", "string"}, {"description", "Glob pattern for files to include (e.g. \"*.cpp\" or \"src/**/*.cpp\"). Default: **"}}},
{"exclude", {{"type", "string"}, {"description", "Glob pattern for files to exclude"}}},
{"path", {{"type", "string"}, {"description", "Base directory to search in"}}},
{"include", {{"type", "string"}, {"description", "Glob pattern for files to include (e.g. \"*.cpp\" or \"src/**/*.cpp\"). Default: **"}}},
{"exclude", {{"type", "string"}, {"description", "Glob pattern for files to exclude"}}},
{"type", {{"type", "string"}, {"description", "Entry type to return: \"file\" (default), \"dir\" or \"all\""}}},
{"max_depth", {{"type", "integer"}, {"description", "Maximum depth to descend into subdirectories (default: 0 = unlimited; 1 = direct children only)"}}},
{"limit", {{"type", "integer"}, {"description", string_format("Maximum number of results to return (default %zu; values below 1 fall back to the default)", SERVER_TOOL_FILE_SEARCH_MAX_RESULTS)}}},
}},
{"required", json::array({"path"})},
}},
@@ -397,30 +486,56 @@ struct server_tool_file_glob_search : server_tool {
}
json invoke(json params, server_tool::stream *) const override {
std::string base = params.at("path").get<std::string>();
std::string include = json_value(params, "include", std::string("**"));
std::string exclude = json_value(params, "exclude", std::string(""));
auto io = make_tools_io(params);
std::string base = io->resolve(params.at("path").get<std::string>());
// normalize to forward slashes so the web UI (which assumes '/') can
// join the relative entries into absolute paths on Windows too
std::replace(base.begin(), base.end(), '\\', '/');
std::string include = json_value(params, "include", std::string("**"));
std::string exclude = json_value(params, "exclude", std::string(""));
std::string type = json_value(params, "type", std::string("file"));
int max_depth = std::max(0, json_value(params, "max_depth", 0));
int limit = json_value(params, "limit", (int) SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
if (limit < 1) limit = SERVER_TOOL_FILE_SEARCH_MAX_RESULTS;
limit = std::min(limit, (int) SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
list_kind kind;
if (type == SERVER_TOOL_FILE_SEARCH_TYPE_FILE) {
kind = list_kind::files;
} else if (type == SERVER_TOOL_FILE_SEARCH_TYPE_DIR) {
kind = list_kind::dirs;
} else if (type == SERVER_TOOL_FILE_SEARCH_TYPE_ALL) {
kind = list_kind::all;
} else {
return {{"error", "invalid type: " + type + " (expected \"file\", \"dir\" or \"all\")"}};
}
std::string err;
auto files = io->list_files(base, err);
bool truncated = false;
auto entries = io->list_entries(base, params.at("path").get<std::string>(), max_depth, kind, err, truncated);
if (!err.empty()) {
return {{"error", err}};
}
std::vector<std::string> matches;
for (const auto & rel : files) {
if (!path_glob_match(include, rel)) continue;
if (!exclude.empty() && path_glob_match(exclude, rel)) continue;
matches.push_back(rel);
std::vector<tools_io::list_entry> matches;
for (const auto & entry : entries) {
if (!path_glob_match(include, entry.rel)) continue;
if (!exclude.empty() && path_glob_match(exclude, entry.rel)) continue;
matches.push_back(entry);
}
size_t total = matches.size();
size_t shown = std::min(total, SERVER_TOOL_FILE_SEARCH_MAX_RESULTS);
size_t shown = std::min(total, (size_t) limit);
std::ostringstream output_text;
json entries_json = json::array();
for (size_t i = 0; i < shown; i++) {
output_text << matches[i] << "\n";
output_text << matches[i].rel << (matches[i].is_dir ? "/" : "") << "\n";
entries_json.push_back({
{"path", matches[i].rel},
{"type", matches[i].is_dir ? "dir" : "file"},
});
}
output_text << "\n---\nTotal matches: " << total << "\n";
@@ -429,8 +544,16 @@ struct server_tool_file_glob_search : server_tool {
"[%zu results limit reached (%zu total matches). Refine the glob pattern to narrow the search.]\n",
shown, total);
}
if (truncated) {
output_text << "[search timed out, results truncated]\n";
}
return {{"plain_text_response", output_text.str()}};
// `base` is always absolute (resolve falls back to the server cwd), so
// API clients (e.g. the web UI picker) can join the relative entries
// into absolute paths. `plain_text_response` is what the model sees;
// `entries` is the same data as structured JSON for the UI picker,
// which reads `entries`/`base` instead of re-parsing the text.
return {{"plain_text_response", output_text.str()}, {"entries", entries_json}, {"base", base}};
}
};
@@ -513,18 +636,20 @@ struct server_tool_grep_search : server_tool {
// collect (absolute_path, display_path) pairs to search
std::vector<std::pair<std::string, std::string>> files;
if (io->is_regular_file(path)) {
files.emplace_back(path, path);
} else if (io->is_directory(path)) {
const std::string abs_path = io->resolve(path);
if (io->is_regular_file(abs_path)) {
files.emplace_back(abs_path, path);
} else if (io->is_directory(abs_path)) {
std::string err;
auto candidates = io->list_files(path, err);
bool truncated = false;
auto candidates = io->list_entries(abs_path, path, 0, list_kind::files, err, truncated);
if (!err.empty()) {
return {{"error", err}};
}
for (const auto & rel : candidates) {
if (!path_glob_match(include, rel)) continue;
if (!exclude.empty() && path_glob_match(exclude, rel)) continue;
files.emplace_back((fs::path(path) / rel).string(), rel);
for (const auto & entry : candidates) {
if (!path_glob_match(include, entry.rel)) continue;
if (!exclude.empty() && path_glob_match(exclude, entry.rel)) continue;
files.emplace_back((fs::path(abs_path) / entry.rel).string(), entry.rel);
}
} else {
return {{"error", "path does not exist: " + path}};
@@ -1094,6 +1219,9 @@ struct server_tool_get_datetime : server_tool {
// get_info: returns runtime info (OS name/version and cwd)
//
static constexpr size_t SERVER_TOOL_GET_INFO_MAX_OUTPUT = 4096;
static constexpr int SERVER_TOOL_GET_INFO_TIMEOUT = 5; // seconds
struct server_tool_get_info : server_tool {
server_tool_get_info() {
name = "get_info";
@@ -1119,9 +1247,9 @@ struct server_tool_get_info : server_tool {
auto io = make_tools_io(params);
#ifdef _WIN32
auto res = io->run({"cmd", "/c", "ver"}, 4096, 5);
auto res = io->run({"cmd", "/c", "ver"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
#else
auto res = io->run({"uname", "-a"}, 4096, 5);
auto res = io->run({"uname", "-a"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
#endif
// "ver" prints a blank line before the version, so the output is stripped on both ends;
// a failed spawn or a timeout leaves a diagnostic in res.output, which is not an OS name