mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-08-08 05:58:11 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8e30266d2 | |||
| a194a75b7e | |||
| 23634783c5 | |||
| 4cb22cd537 | |||
| 4cf5cab65d | |||
| 933f46f3cb | |||
| 9ba73fd1f5 | |||
| f4f7758cae | |||
| 34e9ee57f5 | |||
| dff15d4ac9 |
+2
-2
@@ -4,8 +4,8 @@ project("ggml" C CXX ASM)
|
||||
|
||||
### GGML Version
|
||||
set(GGML_VERSION_MAJOR 0)
|
||||
set(GGML_VERSION_MINOR 18)
|
||||
set(GGML_VERSION_PATCH 1)
|
||||
set(GGML_VERSION_MINOR 19)
|
||||
set(GGML_VERSION_PATCH 0)
|
||||
set(GGML_VERSION_BASE "${GGML_VERSION_MAJOR}.${GGML_VERSION_MINOR}.${GGML_VERSION_PATCH}")
|
||||
|
||||
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/")
|
||||
|
||||
@@ -3816,7 +3816,7 @@ int ggml_metal_op_norm(ggml_metal_op_t ctx, int idx) {
|
||||
}
|
||||
|
||||
nth = std::min(nth, ggml_metal_pipeline_max_theads_per_threadgroup(pipeline));
|
||||
nth = std::min(nth, args.ne00_t);
|
||||
nth = std::min(nth, (args.ne00_t + 31)/32*32);
|
||||
|
||||
const size_t smem = pipeline.smem;
|
||||
|
||||
|
||||
@@ -36,9 +36,13 @@ static void kernel_ssm_conv(
|
||||
return;
|
||||
}
|
||||
|
||||
const int channel = static_cast<int>(idx % d_inner);
|
||||
const int token = static_cast<int>((idx / d_inner) % n_t);
|
||||
const int seq = static_cast<int>(idx / (static_cast<size_t>(d_inner) * static_cast<size_t>(n_t)));
|
||||
// src has the tokens of one channel contiguous, dst has the channels of one
|
||||
// token contiguous, so either the loads or the store must be strided. Indexing
|
||||
// token-fastest coalesces the d_conv loads, which measured faster except for
|
||||
// short, cache-resident rows.
|
||||
const int token = static_cast<int>(idx % n_t);
|
||||
const int channel = static_cast<int>((idx / n_t) % d_inner);
|
||||
const int seq = static_cast<int>(idx / (static_cast<size_t>(n_t) * static_cast<size_t>(d_inner)));
|
||||
|
||||
const float *s = src_data
|
||||
+ static_cast<size_t>(seq) * static_cast<size_t>(src_stride_seq)
|
||||
|
||||
@@ -1 +1 @@
|
||||
90951f99af1fbebef3fbdd58ff5b8715b0bb9c43
|
||||
30bf8685ed4eb0a47f2b06229543327749904150
|
||||
|
||||
@@ -8722,6 +8722,13 @@ static std::vector<std::unique_ptr<test_case>> make_test_cases_eval() {
|
||||
test_cases.emplace_back(new test_l2_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, eps, true));
|
||||
test_cases.emplace_back(new test_l2_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, eps, false, true));
|
||||
}
|
||||
// row lengths that are not a multiple of 32, for the scalar (33) and float4 (132, 260) paths
|
||||
for (uint32_t n : { 33, 132, 260 }) {
|
||||
for (bool v : { false, true }) {
|
||||
test_cases.emplace_back(new test_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, v, eps));
|
||||
test_cases.emplace_back(new test_rms_norm(GGML_TYPE_F32, { n, 5, 4, 3 }, v, eps));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// in-place tests
|
||||
|
||||
@@ -170,6 +170,17 @@ struct clip_hparams {
|
||||
warmup_image_size = static_cast<int>(std::sqrt(image_max_pixels));
|
||||
}
|
||||
|
||||
// used by longest_edge preprocessor (no model-specific value for min/max tokens)
|
||||
void set_limit_image_tokens() {
|
||||
const int patch_area = patch_size * patch_size * n_merge * n_merge;
|
||||
if (custom_image_min_tokens > 0) {
|
||||
image_min_pixels = custom_image_min_tokens * patch_area;
|
||||
}
|
||||
if (custom_image_max_tokens > 0) {
|
||||
image_max_pixels = custom_image_max_tokens * patch_area;
|
||||
}
|
||||
}
|
||||
|
||||
void set_warmup_n_tokens(int n_tokens) {
|
||||
int n_tok_per_side = static_cast<int>(std::sqrt(n_tokens));
|
||||
GGML_ASSERT(n_tok_per_side * n_tok_per_side == n_tokens && "n_tokens must be n*n");
|
||||
|
||||
@@ -1434,6 +1434,7 @@ struct clip_model_loader {
|
||||
// use default llava-uhd preprocessing params
|
||||
get_u32(KEY_PROJ_SCALE_FACTOR, hparams.n_merge, false);
|
||||
get_u32(KEY_PREPROC_IMAGE_SIZE, hparams.image_longest_edge, false);
|
||||
hparams.set_limit_image_tokens();
|
||||
} break;
|
||||
case PROJECTOR_TYPE_LFM2:
|
||||
{
|
||||
@@ -1471,6 +1472,7 @@ struct clip_model_loader {
|
||||
get_u32(KEY_SPATIAL_MERGE_SIZE, hparams.n_merge, false);
|
||||
hparams.image_longest_edge = hparams.image_size;
|
||||
get_u32(KEY_PREPROC_IMAGE_SIZE, hparams.image_longest_edge, false);
|
||||
hparams.set_limit_image_tokens();
|
||||
hparams.set_warmup_n_tokens(256); // avoid OOM on warmup
|
||||
} break;
|
||||
case PROJECTOR_TYPE_DOTS_OCR:
|
||||
@@ -1595,6 +1597,7 @@ struct clip_model_loader {
|
||||
if (hparams.image_longest_edge == 0) {
|
||||
hparams.image_longest_edge = 3024;
|
||||
}
|
||||
// note: the step3vl preprocessor slices based on a fixed window grid, so it does not support custom min/max image tokens
|
||||
hparams.warmup_image_size = hparams.image_size;
|
||||
} break;
|
||||
case PROJECTOR_TYPE_YOUTUVL:
|
||||
|
||||
+51
-47
@@ -139,50 +139,46 @@ struct img_tool {
|
||||
}
|
||||
}
|
||||
|
||||
// calculate the size of the **resized** image, while preserving the aspect ratio
|
||||
// the calculated size will be aligned to the nearest multiple of align_size
|
||||
// if H or W size is larger than longest_edge, it will be resized to longest_edge
|
||||
static clip_image_size calc_size_preserved_ratio(const clip_image_size & inp_size, const int align_size, const int longest_edge) {
|
||||
GGML_ASSERT(align_size > 0);
|
||||
if (inp_size.width <= 0 || inp_size.height <= 0 || longest_edge <= 0) {
|
||||
struct calc_size_opt {
|
||||
int align_size = 1;
|
||||
int min_pixels = 0; // 0 = disabled
|
||||
int max_pixels = 0; // 0 = disabled
|
||||
// applied before min/max_pixels, so min_pixels can push an edge back above longest_edge
|
||||
int longest_edge = 0; // 0 = disabled
|
||||
};
|
||||
|
||||
// calculate the size of the **resized** image, while preserving the aspect ratio and
|
||||
// aligning to the nearest multiple of align_size ("smart_resize" in transformers code)
|
||||
static clip_image_size calc_size_preserved_ratio(const clip_image_size & inp_size, const calc_size_opt & opts) {
|
||||
GGML_ASSERT(opts.align_size > 0);
|
||||
const int width = inp_size.width;
|
||||
const int height = inp_size.height;
|
||||
if (width <= 0 || height <= 0) {
|
||||
return {0, 0};
|
||||
}
|
||||
|
||||
float scale = std::min(static_cast<float>(longest_edge) / inp_size.width,
|
||||
static_cast<float>(longest_edge) / inp_size.height);
|
||||
auto round_by_factor = [f = opts.align_size](float x) { return static_cast<int>(std::round(x / static_cast<float>(f))) * f; };
|
||||
auto ceil_by_factor = [f = opts.align_size](float x) { return static_cast<int>(std::ceil(x / static_cast<float>(f))) * f; };
|
||||
auto floor_by_factor = [f = opts.align_size](float x) { return static_cast<int>(std::floor(x / static_cast<float>(f))) * f; };
|
||||
|
||||
float target_width_f = static_cast<float>(inp_size.width) * scale;
|
||||
float target_height_f = static_cast<float>(inp_size.height) * scale;
|
||||
int w_bar, h_bar;
|
||||
if (opts.longest_edge > 0) {
|
||||
const float scale = std::min(static_cast<float>(opts.longest_edge) / width,
|
||||
static_cast<float>(opts.longest_edge) / height);
|
||||
w_bar = ceil_by_factor(width * scale);
|
||||
h_bar = ceil_by_factor(height * scale);
|
||||
} else {
|
||||
// always align up first
|
||||
w_bar = std::max(opts.align_size, round_by_factor(width));
|
||||
h_bar = std::max(opts.align_size, round_by_factor(height));
|
||||
}
|
||||
|
||||
auto ceil_by_factor = [f = align_size](float x) { return static_cast<int>(std::ceil(x / static_cast<float>(f))) * f; };
|
||||
int aligned_width = ceil_by_factor(target_width_f);
|
||||
int aligned_height = ceil_by_factor(target_height_f);
|
||||
|
||||
return {aligned_width, aligned_height};
|
||||
}
|
||||
|
||||
// calculate the size of the **resized** image, while preserving the aspect ratio
|
||||
// the calculated size will have min_pixels <= W*H <= max_pixels
|
||||
// this is referred as "smart_resize" in transformers code
|
||||
static clip_image_size calc_size_preserved_ratio(const clip_image_size & inp_size, const int align_size, const int min_pixels, const int max_pixels) {
|
||||
GGML_ASSERT(align_size > 0);
|
||||
const int width = inp_size.width;
|
||||
const int height = inp_size.height;
|
||||
|
||||
auto round_by_factor = [f = align_size](float x) { return static_cast<int>(std::round(x / static_cast<float>(f))) * f; };
|
||||
auto ceil_by_factor = [f = align_size](float x) { return static_cast<int>(std::ceil(x / static_cast<float>(f))) * f; };
|
||||
auto floor_by_factor = [f = align_size](float x) { return static_cast<int>(std::floor(x / static_cast<float>(f))) * f; };
|
||||
|
||||
// always align up first
|
||||
int h_bar = std::max(align_size, round_by_factor(height));
|
||||
int w_bar = std::max(align_size, round_by_factor(width));
|
||||
|
||||
if (h_bar * w_bar > max_pixels) {
|
||||
const auto beta = std::sqrt(static_cast<float>(height * width) / max_pixels);
|
||||
h_bar = std::max(align_size, floor_by_factor(height / beta));
|
||||
w_bar = std::max(align_size, floor_by_factor(width / beta));
|
||||
} else if (h_bar * w_bar < min_pixels) {
|
||||
const auto beta = std::sqrt(static_cast<float>(min_pixels) / (height * width));
|
||||
if (opts.max_pixels > 0 && h_bar * w_bar > opts.max_pixels) {
|
||||
const auto beta = std::sqrt(static_cast<float>(height) * width / opts.max_pixels);
|
||||
h_bar = std::max(opts.align_size, floor_by_factor(height / beta));
|
||||
w_bar = std::max(opts.align_size, floor_by_factor(width / beta));
|
||||
} else if (opts.min_pixels > 0 && h_bar * w_bar < opts.min_pixels) {
|
||||
const auto beta = std::sqrt(static_cast<float>(opts.min_pixels) / (static_cast<float>(height) * width));
|
||||
h_bar = ceil_by_factor(height * beta);
|
||||
w_bar = ceil_by_factor(width * beta);
|
||||
}
|
||||
@@ -937,9 +933,12 @@ mtmd_image_preproc_out mtmd_image_preprocessor_dyn_size::preprocess(const clip_i
|
||||
const int cur_merge = hparams.n_merge;
|
||||
const clip_image_size target_size = img_tool::calc_size_preserved_ratio(
|
||||
original_size,
|
||||
hparams.patch_size * cur_merge,
|
||||
hparams.image_min_pixels,
|
||||
hparams.image_max_pixels);
|
||||
{
|
||||
/* align_size */ hparams.patch_size * cur_merge,
|
||||
/* min_pixels */ hparams.image_min_pixels,
|
||||
/* max_pixels */ hparams.image_max_pixels,
|
||||
/* longest_edge */ 0,
|
||||
});
|
||||
img_tool::resize(img, resized_image, target_size,
|
||||
hparams.image_resize_algo,
|
||||
hparams.image_resize_pad,
|
||||
@@ -961,8 +960,12 @@ mtmd_image_preproc_out mtmd_image_preprocessor_longest_edge::preprocess(const cl
|
||||
const int cur_merge = hparams.n_merge == 0 ? 1 : hparams.n_merge;
|
||||
const clip_image_size target_size = img_tool::calc_size_preserved_ratio(
|
||||
original_size,
|
||||
hparams.patch_size * cur_merge,
|
||||
hparams.image_longest_edge);
|
||||
{
|
||||
/* align_size */ hparams.patch_size * cur_merge,
|
||||
/* min_pixels */ std::max(0, hparams.image_min_pixels),
|
||||
/* max_pixels */ std::max(0, hparams.image_max_pixels),
|
||||
/* longest_edge */ hparams.image_longest_edge,
|
||||
});
|
||||
img_tool::resize(img, resized_image, target_size,
|
||||
hparams.image_resize_algo,
|
||||
hparams.image_resize_pad,
|
||||
@@ -1000,8 +1003,8 @@ mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_lf
|
||||
mtmd_image_preprocessor_llava_uhd::slice_instructions inst;
|
||||
const int align_size = hparams.patch_size * hparams.n_merge;
|
||||
inst.overview_size = img_tool::calc_size_preserved_ratio(
|
||||
original_size, align_size,
|
||||
hparams.image_min_pixels, hparams.image_max_pixels);
|
||||
original_size,
|
||||
{ align_size, hparams.image_min_pixels, hparams.image_max_pixels, 0 });
|
||||
// tile if either dimension exceeds tile_size with tolerance
|
||||
const bool needs_tiling = original_size.width > tile_size * max_pixels_tolerance || original_size.height > tile_size * max_pixels_tolerance;
|
||||
|
||||
@@ -1109,7 +1112,8 @@ mtmd_image_preproc_out mtmd_image_preprocessor_idefics3::preprocess(const clip_i
|
||||
// CITE: https://github.com/huggingface/transformers/blob/main/src/transformers/models/idefics3/image_processing_idefics3.py#L737
|
||||
const clip_image_size original_size = img.get_size();
|
||||
const clip_image_size refined_size = img_tool::calc_size_preserved_ratio(
|
||||
original_size, hparams.image_size, hparams.image_longest_edge);
|
||||
original_size,
|
||||
{ hparams.image_size, std::max(0, hparams.image_min_pixels), std::max(0, hparams.image_max_pixels), hparams.image_longest_edge });
|
||||
// LOG_INF("%s: original size: %d x %d, refined size: %d x %d\n",
|
||||
// __func__, original_size.width, original_size.height,
|
||||
// refined_size.width, refined_size.height);
|
||||
|
||||
+320
-39
@@ -70,6 +70,188 @@ struct server_subproc {
|
||||
}
|
||||
};
|
||||
|
||||
struct server_lru_sched {
|
||||
server_lru_sched(server_models & models) : models(models) {}
|
||||
|
||||
bool has_capacity(std::unique_lock<std::mutex> & lk) {
|
||||
check_lock(lk);
|
||||
return models.base_params.models_max <= 0
|
||||
|| count_running() < (size_t) models.base_params.models_max;
|
||||
}
|
||||
|
||||
// returns "" if no model can be given up
|
||||
std::string pick_victim(std::unique_lock<std::mutex> & lk, const std::string & exclude) {
|
||||
check_lock(lk);
|
||||
std::string victim;
|
||||
int64_t victim_last_used = 0;
|
||||
for (const auto & m : models.mapping) {
|
||||
if (m.first == exclude) {
|
||||
continue;
|
||||
}
|
||||
// a busy model is mid-request, one still coming up has no request to finish
|
||||
if (m.second.req_count != 0 || !m.second.meta.is_ready_or_sleep()) {
|
||||
continue;
|
||||
}
|
||||
if (victim.empty() || m.second.meta.last_used < victim_last_used) {
|
||||
victim = m.first;
|
||||
victim_last_used = m.second.meta.last_used;
|
||||
}
|
||||
}
|
||||
return victim;
|
||||
}
|
||||
|
||||
// requests wanting the same model share one entry, so they all need only one slot
|
||||
// and all get unblocked by the single load that entry performs
|
||||
void join(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
|
||||
check_lock(lk);
|
||||
if (entry_t * e = find(model_id)) {
|
||||
e->n_waiters++;
|
||||
SRV_INF("request for name=%s joined the queue, %d waiting\n", model_id.c_str(), e->n_waiters);
|
||||
return;
|
||||
}
|
||||
queue.push_back({ model_id, 1, false, false });
|
||||
SRV_INF("models_max reached, request for name=%s queued at position %zu\n",
|
||||
model_id.c_str(), queue.size());
|
||||
}
|
||||
|
||||
void leave(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
|
||||
check_lock(lk);
|
||||
for (auto it = queue.begin(); it != queue.end(); ++it) {
|
||||
if (it->model_id == model_id) {
|
||||
if (--it->n_waiters <= 0) {
|
||||
queue.erase(it); // last one waiting for this model went away
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool queue_empty(std::unique_lock<std::mutex> & lk) {
|
||||
check_lock(lk);
|
||||
return queue.empty();
|
||||
}
|
||||
|
||||
// true if it is this model's turn to load, and nobody is loading it yet
|
||||
bool try_claim(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
|
||||
check_lock(lk);
|
||||
if (queue.empty() || queue.front().model_id != model_id || queue.front().loading) {
|
||||
return false;
|
||||
}
|
||||
if (!has_capacity(lk)) {
|
||||
return false;
|
||||
}
|
||||
queue.front().loading = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ok means the model is up: drop the entry, the other waiters just watch its status now
|
||||
void claim_done(std::unique_lock<std::mutex> & lk, const std::string & model_id, bool ok) {
|
||||
check_lock(lk);
|
||||
for (auto it = queue.begin(); it != queue.end(); ++it) {
|
||||
if (it->model_id == model_id) {
|
||||
if (ok) {
|
||||
queue.erase(it);
|
||||
} else {
|
||||
it->loading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// a model is on its way out for this entry, so other requests do not also give up one
|
||||
void mark_slot_pending(std::unique_lock<std::mutex> & lk, const std::string & model_id) {
|
||||
check_lock(lk);
|
||||
if (entry_t * e = find(model_id)) {
|
||||
e->slot_pending = true;
|
||||
}
|
||||
}
|
||||
|
||||
// model_id went idle: give up its slot if a queued request needs one
|
||||
// thread-safe, caller must NOT hold models.mutex
|
||||
void on_model_idle(const std::string & model_id) {
|
||||
if (models.base_params.models_max <= 0) {
|
||||
return; // no limit, nothing is ever queued
|
||||
}
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(models.mutex);
|
||||
if (queue.empty()) {
|
||||
return;
|
||||
}
|
||||
size_t promised = 0;
|
||||
bool has_unserved = false;
|
||||
for (const auto & e : queue) {
|
||||
if (e.needs_slot()) {
|
||||
has_unserved = true;
|
||||
} else {
|
||||
promised++;
|
||||
}
|
||||
}
|
||||
if (!has_unserved) {
|
||||
return;
|
||||
}
|
||||
if ((int) count_running() - (int) promised < models.base_params.models_max) {
|
||||
return; // a slot is already on its way
|
||||
}
|
||||
// never give up a model that a queued request wants
|
||||
for (const auto & e : queue) {
|
||||
if (e.model_id == model_id) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
auto it = models.mapping.find(model_id);
|
||||
if (it == models.mapping.end() || it->second.req_count != 0 || !it->second.meta.is_ready_or_sleep()) {
|
||||
return;
|
||||
}
|
||||
for (auto & e : queue) {
|
||||
if (!e.slot_pending) {
|
||||
e.slot_pending = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
SRV_INF("model name=%s went idle, giving up its slot to a queued request\n", model_id.c_str());
|
||||
models.unload(model_id);
|
||||
}
|
||||
|
||||
private:
|
||||
struct entry_t {
|
||||
std::string model_id;
|
||||
int n_waiters; // requests waiting for this model
|
||||
bool slot_pending; // a model is already being evicted for this entry
|
||||
bool loading; // one of the waiters is doing the load right now
|
||||
|
||||
// a slot is already coming, or already taken by the load in flight
|
||||
bool needs_slot() const { return !slot_pending && !loading; }
|
||||
};
|
||||
|
||||
entry_t * find(const std::string & model_id) {
|
||||
for (auto & e : queue) {
|
||||
if (e.model_id == model_id) {
|
||||
return &e;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void check_lock(std::unique_lock<std::mutex> & lk) {
|
||||
GGML_ASSERT(lk.owns_lock() && lk.mutex() == &models.mutex);
|
||||
}
|
||||
|
||||
size_t count_running() {
|
||||
size_t count = 0;
|
||||
for (const auto & m : models.mapping) {
|
||||
if (m.second.meta.is_running()) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
server_models & models;
|
||||
std::deque<entry_t> queue;
|
||||
};
|
||||
|
||||
// short loopback budget for the resumable stream router to child JSON calls (probe, lookup,
|
||||
// delete). distinct from params.timeout_read/write which only applies to the generation proxy
|
||||
static constexpr int STREAM_LOOKUP_TIMEOUT_MS = 250;
|
||||
@@ -229,7 +411,8 @@ server_models::server_models(
|
||||
: ctx_preset(LLAMA_EXAMPLE_SERVER),
|
||||
base_params(params),
|
||||
base_env(get_environment()),
|
||||
base_preset(ctx_preset.load_from_args(argc, argv)) {
|
||||
base_preset(ctx_preset.load_from_args(argc, argv)),
|
||||
sched(std::make_unique<server_lru_sched>(*this)) {
|
||||
// clean up base preset
|
||||
unset_reserved_args(base_preset, true);
|
||||
// set binary path
|
||||
@@ -241,8 +424,11 @@ server_models::server_models(
|
||||
LOG_WRN("using original argv[0] as fallback: %s\n", argv[0]);
|
||||
}
|
||||
load_models();
|
||||
debug_fake_timing = !common_get_env("LLAMA_SERVER_DEBUG_FAKE_TIMING").empty();
|
||||
}
|
||||
|
||||
server_models::~server_models() = default;
|
||||
|
||||
void server_models::add_model(server_model_meta && meta) {
|
||||
if (mapping.find(meta.name) != mapping.end()) {
|
||||
throw std::runtime_error(string_format("model '%s' appears multiple times", meta.name.c_str()));
|
||||
@@ -713,24 +899,15 @@ void server_models::unload_lru() {
|
||||
return; // no limit
|
||||
}
|
||||
// remove one of the servers if we passed the models_max (least recently used - LRU)
|
||||
std::string lru_model_name = "";
|
||||
int64_t lru_last_used = ggml_time_ms();
|
||||
size_t count_active = 0;
|
||||
std::string lru_model_name;
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(mutex);
|
||||
for (const auto & m : mapping) {
|
||||
if (m.second.meta.is_running()) {
|
||||
count_active++;
|
||||
// do not evict busy one
|
||||
bool is_model_idle = m.second.req_count == 0 && m.second.meta.is_ready_or_sleep();
|
||||
if (is_model_idle && m.second.meta.last_used < lru_last_used) {
|
||||
lru_model_name = m.first;
|
||||
lru_last_used = m.second.meta.last_used;
|
||||
}
|
||||
}
|
||||
if (sched->has_capacity(lk)) {
|
||||
return;
|
||||
}
|
||||
lru_model_name = sched->pick_victim(lk, "");
|
||||
}
|
||||
if (!lru_model_name.empty() && count_active >= (size_t)base_params.models_max) {
|
||||
if (!lru_model_name.empty()) {
|
||||
SRV_INF("models_max limit reached, removing LRU name=%s\n", lru_model_name.c_str());
|
||||
unload(lru_model_name);
|
||||
// wait for unload to complete
|
||||
@@ -741,7 +918,6 @@ void server_models::unload_lru() {
|
||||
});
|
||||
}
|
||||
}
|
||||
// TODO @ngxson : if no idle model is found, queue the load request
|
||||
}
|
||||
|
||||
void server_models::load(const std::string & name) {
|
||||
@@ -749,6 +925,11 @@ void server_models::load(const std::string & name) {
|
||||
}
|
||||
|
||||
void server_models::load(const std::string & name, const load_options & opts) {
|
||||
if (debug_fake_timing) {
|
||||
// do not hold the mutex here, other requests must keep making progress
|
||||
std::this_thread::sleep_for(std::chrono::seconds(2));
|
||||
}
|
||||
|
||||
if (!opts.custom_meta.has_value()) {
|
||||
if (!has_model(name)) {
|
||||
throw std::runtime_error("model name=" + name + " is not found");
|
||||
@@ -1141,7 +1322,7 @@ void server_models::wait(std::unique_lock<std::mutex> & lk, const std::string &
|
||||
});
|
||||
}
|
||||
|
||||
bool server_models::ensure_model_ready(const std::string & name) {
|
||||
bool server_models::ensure_model_ready(const std::string & name, const std::function<bool()> & should_stop) {
|
||||
auto meta = get_meta(name);
|
||||
if (!meta.has_value()) {
|
||||
throw std::runtime_error("model name=" + name + " is not found");
|
||||
@@ -1152,25 +1333,112 @@ bool server_models::ensure_model_ready(const std::string & name) {
|
||||
if (meta->status == SERVER_MODEL_STATUS_SLEEPING) {
|
||||
return false; // child is sleeping but still running; new request will wake it up
|
||||
}
|
||||
if (meta->status == SERVER_MODEL_STATUS_UNLOADED) {
|
||||
SRV_INF("model name=%s is not loaded, loading...\n", name.c_str());
|
||||
load(name);
|
||||
}
|
||||
|
||||
// wait for loading to complete
|
||||
SRV_INF("waiting until model name=%s is fully loaded...\n", name.c_str());
|
||||
wait(name, [&meta](const server_model_meta & new_meta) {
|
||||
if (new_meta.status != SERVER_MODEL_STATUS_LOADING) {
|
||||
meta = new_meta; // update meta for final check after wait
|
||||
return true;
|
||||
bool queued = false;
|
||||
bool did_load = false;
|
||||
std::string victim;
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(mutex);
|
||||
auto it = mapping.find(name);
|
||||
if (it != mapping.end() && it->second.meta.status == SERVER_MODEL_STATUS_UNLOADED) {
|
||||
bool has_capacity = sched->has_capacity(lk);
|
||||
if (has_capacity && sched->queue_empty(lk)) {
|
||||
lk.unlock();
|
||||
SRV_INF("model name=%s is not loaded, loading...\n", name.c_str());
|
||||
load(name);
|
||||
did_load = true;
|
||||
} else {
|
||||
// also queue when a slot looks free but others wait already, else they starve
|
||||
sched->join(lk, name);
|
||||
queued = true;
|
||||
if (!has_capacity) {
|
||||
// an idle model may sit here right now, do not wait for a request to end
|
||||
victim = sched->pick_victim(lk, name);
|
||||
if (!victim.empty()) {
|
||||
sched->mark_slot_pending(lk, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// check final status
|
||||
if (!meta.has_value() || meta->is_failed()) {
|
||||
throw std::runtime_error("model name=" + name + " failed to load");
|
||||
}
|
||||
if (!victim.empty()) {
|
||||
SRV_INF("evicting idle LRU name=%s to make room for name=%s\n", victim.c_str(), name.c_str());
|
||||
unload(victim);
|
||||
}
|
||||
|
||||
// while queued, this is also where the load happens: the head of the queue does it
|
||||
SRV_INF("waiting until model name=%s is fully loaded...\n", name.c_str());
|
||||
std::unique_lock<std::mutex> lk(mutex);
|
||||
auto leave_queue = [this, &queued, &lk, &name]() {
|
||||
if (queued) {
|
||||
sched->leave(lk, name);
|
||||
queued = false;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
bool saw_loading = false;
|
||||
while (true) {
|
||||
auto it = mapping.find(name);
|
||||
if (it == mapping.end()) {
|
||||
break; // removed by another code path, nothing to wait for
|
||||
}
|
||||
const server_model_status status = it->second.meta.status;
|
||||
|
||||
if (status == SERVER_MODEL_STATUS_LOADED || status == SERVER_MODEL_STATUS_SLEEPING) {
|
||||
break;
|
||||
}
|
||||
if (status == SERVER_MODEL_STATUS_DOWNLOADING || status == SERVER_MODEL_STATUS_DOWNLOADED) {
|
||||
break; // do not wait on a download child
|
||||
}
|
||||
if (status == SERVER_MODEL_STATUS_LOADING) {
|
||||
saw_loading = true;
|
||||
} else if (status == SERVER_MODEL_STATUS_UNLOADED) {
|
||||
if (did_load || saw_loading) {
|
||||
// a spawn happened and the instance came back down
|
||||
if (it->second.meta.is_failed()) {
|
||||
throw std::runtime_error("model name=" + name + " failed to load");
|
||||
}
|
||||
break; // unloaded by another code path, caller reports "not running"
|
||||
}
|
||||
if (!queued) {
|
||||
break; // not queued, and the load someone else started fell over
|
||||
}
|
||||
}
|
||||
|
||||
if (should_stop && should_stop()) {
|
||||
// if a model was evicted for us, the free slot goes to the next waiter
|
||||
throw std::runtime_error("request cancelled while waiting for model name=" + name);
|
||||
}
|
||||
|
||||
// our turn: our model is at the head, and a slot really did free up
|
||||
if (status == SERVER_MODEL_STATUS_UNLOADED && sched->try_claim(lk, name)) {
|
||||
lk.unlock();
|
||||
bool ok = true;
|
||||
try {
|
||||
SRV_INF("slot available, loading queued model name=%s\n", name.c_str());
|
||||
load(name);
|
||||
did_load = true;
|
||||
} catch (const std::exception & e) {
|
||||
// lost a race for the slot, stay in line and retry
|
||||
SRV_WRN("queued load of name=%s did not go through: %s\n", name.c_str(), e.what());
|
||||
ok = false;
|
||||
}
|
||||
lk.lock();
|
||||
sched->claim_done(lk, name, ok);
|
||||
if (ok) {
|
||||
queued = false; // entry is gone, the other waiters watch the status now
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
cv.wait_for(lk, std::chrono::milliseconds(200));
|
||||
}
|
||||
} catch (...) {
|
||||
leave_queue();
|
||||
throw;
|
||||
}
|
||||
leave_queue();
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1190,6 +1458,10 @@ server_http_res_ptr server_models::proxy_request(const server_http_req & req, co
|
||||
}
|
||||
mapping[name].req_count++;
|
||||
}
|
||||
if (debug_fake_timing) {
|
||||
// sleep after req_count++, so the model counts as busy while we wait here
|
||||
std::this_thread::sleep_for(std::chrono::seconds(2));
|
||||
}
|
||||
SRV_INF("proxying request to model %s on port %d\n", name.c_str(), meta->port);
|
||||
std::string proxy_path = req.path;
|
||||
if (!req.query_string.empty()) {
|
||||
@@ -1213,10 +1485,17 @@ server_http_res_ptr server_models::proxy_request(const server_http_req & req, co
|
||||
);
|
||||
|
||||
proxy->cleanup = [this, name]() {
|
||||
std::unique_lock<std::mutex> lk(mutex);
|
||||
auto it = mapping.find(name);
|
||||
if (it != mapping.end() && it->second.req_count > 0) {
|
||||
it->second.req_count--;
|
||||
bool went_idle = false;
|
||||
{
|
||||
std::unique_lock<std::mutex> lk(mutex);
|
||||
auto it = mapping.find(name);
|
||||
if (it != mapping.end() && it->second.req_count > 0) {
|
||||
it->second.req_count--;
|
||||
went_idle = it->second.req_count == 0;
|
||||
}
|
||||
}
|
||||
if (went_idle) {
|
||||
sched->on_model_idle(name);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1583,7 +1862,7 @@ void server_models_routes::init_routes() {
|
||||
return error_res;
|
||||
}
|
||||
if (autoload) {
|
||||
models.ensure_model_ready(name);
|
||||
models.ensure_model_ready(name, req.should_stop);
|
||||
}
|
||||
return models.proxy_request(req, method, name, false);
|
||||
};
|
||||
@@ -1603,7 +1882,9 @@ void server_models_routes::init_routes() {
|
||||
// this request instead of leaving an orphan generation
|
||||
std::string conv_id = server_stream_conv_id_from_headers(req.headers);
|
||||
uint64_t ticket = models.conv_models.remember(conv_id, name);
|
||||
bool waited = autoload && models.ensure_model_ready(name);
|
||||
// a dead socket must not cancel a session request, only a stop does (checked right below)
|
||||
auto should_stop = ticket == 0 ? req.should_stop : nullptr;
|
||||
bool waited = autoload && models.ensure_model_ready(name, should_stop);
|
||||
if (ticket != 0 && !models.conv_models.alive(conv_id, ticket)) {
|
||||
SRV_INF("request for conv_id=%s cancelled while model name=%s was loading\n",
|
||||
conv_id.c_str(), name.c_str());
|
||||
|
||||
@@ -106,10 +106,12 @@ struct server_model_meta {
|
||||
};
|
||||
|
||||
struct server_models_routes;
|
||||
struct server_subproc; // defined in server-models.cpp
|
||||
struct server_subproc; // defined in server-models.cpp
|
||||
struct server_lru_sched; // defined in server-models.cpp
|
||||
|
||||
struct server_models {
|
||||
friend struct server_models_routes;
|
||||
friend struct server_lru_sched;
|
||||
|
||||
private:
|
||||
struct instance_t {
|
||||
@@ -195,6 +197,12 @@ private:
|
||||
std::vector<std::string> base_env;
|
||||
common_preset base_preset; // base preset from llama-server CLI args
|
||||
|
||||
// queue of requests waiting for a models_max slot
|
||||
std::unique_ptr<server_lru_sched> sched;
|
||||
|
||||
// if true, add some delay to simulate works (useful for testing)
|
||||
bool debug_fake_timing = false;
|
||||
|
||||
void update_meta(const std::string & name, const server_model_meta & meta);
|
||||
|
||||
// unload least recently used models if the limit is reached
|
||||
@@ -211,6 +219,7 @@ public:
|
||||
conv_model_tracker conv_models;
|
||||
|
||||
server_models(const common_params & params, int argc, char ** argv);
|
||||
~server_models();
|
||||
|
||||
server_response sse; // for real-time updates via SSE endpoint
|
||||
|
||||
@@ -267,7 +276,9 @@ public:
|
||||
// ensure the model is in ready state (thread-safe)
|
||||
// return false if model is ready
|
||||
// otherwise, load the model and blocking wait until it's ready, then return true (meta may need to be refreshed)
|
||||
bool ensure_model_ready(const std::string & name);
|
||||
// if models_max is reached, the request waits in a queue until a slot frees up
|
||||
// throws if the load fails, or if should_stop fires while waiting
|
||||
bool ensure_model_ready(const std::string & name, const std::function<bool()> & should_stop = nullptr);
|
||||
|
||||
// proxy an HTTP request to the model instance
|
||||
server_http_res_ptr proxy_request(const server_http_req & req, const std::string & method, const std::string & name, bool update_last_used, bool detached = false);
|
||||
|
||||
@@ -519,7 +519,7 @@ task_params eval_llama_cmpl_schema(
|
||||
const json & data) {
|
||||
task_params params;
|
||||
|
||||
// Sampling parameter defaults are loaded from the global server context (but individual requests can still them)
|
||||
// Sampling parameter defaults are loaded from the global server context (but individual requests can still override them)
|
||||
params.sampling = params_base.sampling;
|
||||
params.speculative = params_base.speculative;
|
||||
params.n_keep = params_base.n_keep;
|
||||
|
||||
@@ -145,6 +145,156 @@ def test_router_models_max_evicts_lru():
|
||||
assert _get_model_status(first) == "unloaded"
|
||||
|
||||
|
||||
# server_lru_sched tests (relying on LLAMA_SERVER_DEBUG_FAKE_TIMING)
|
||||
|
||||
MODEL_A = "ggml-org/tinygemma3-GGUF:Q8_0"
|
||||
MODEL_B = "ggml-org/test-model-stories260K:F32"
|
||||
MODEL_C = "ggml-org/test-model-stories260K-infill:F32"
|
||||
|
||||
|
||||
def _tokenize(model_id: str, timeout: float | None = DEFAULT_REQUEST_TIMEOUT) -> ServerResponse:
|
||||
return server.make_request(
|
||||
"POST", "/tokenize", data={"model": model_id, "content": "hello world"}, timeout=timeout
|
||||
)
|
||||
|
||||
|
||||
class _Bg:
|
||||
"""runs one request in a thread, keeps its result, error and finish time"""
|
||||
|
||||
def __init__(self, fn):
|
||||
self.result = None
|
||||
self.error: Exception | None = None
|
||||
self.done_at: float = 0.0
|
||||
self._thread = threading.Thread(target=self._run, args=(fn,), daemon=True)
|
||||
|
||||
def _run(self, fn):
|
||||
try:
|
||||
self.result = fn()
|
||||
except Exception as e:
|
||||
self.error = e
|
||||
self.done_at = time.time()
|
||||
|
||||
def start(self):
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def join(self, timeout: int = 180):
|
||||
self._thread.join(timeout)
|
||||
assert not self._thread.is_alive(), "background request did not finish in time"
|
||||
return self
|
||||
|
||||
def assert_ok(self, what: str):
|
||||
assert self.error is None, f"{what} raised {self.error!r}"
|
||||
assert self.result is not None and self.result.status_code == 200, \
|
||||
f"{what} failed: {self.result.status_code if self.result else None} {self.result.body if self.result else None}"
|
||||
|
||||
|
||||
def test_router_queue_does_not_evict_busy_model():
|
||||
"""a request that finds no free slot waits, and the model serving a request survives it"""
|
||||
global server
|
||||
server.models_max = 1
|
||||
server.start()
|
||||
|
||||
_load_model_and_wait(MODEL_A, timeout=120)
|
||||
|
||||
busy = _Bg(lambda: _tokenize(MODEL_A)).start()
|
||||
time.sleep(0.5) # let the request reach the child and take the only slot
|
||||
|
||||
# no slot free and MODEL_A is busy, so this queues instead of evicting mid-request
|
||||
queued = _Bg(lambda: _tokenize(MODEL_B)).start()
|
||||
|
||||
busy.join()
|
||||
queued.join()
|
||||
|
||||
# had MODEL_A been evicted while serving, its own request would have died
|
||||
busy.assert_ok("request against the busy model")
|
||||
queued.assert_ok("queued request")
|
||||
|
||||
_wait_for_model_status(MODEL_B, {"loaded"}, timeout=120)
|
||||
assert _get_model_status(MODEL_A) == "unloaded"
|
||||
|
||||
|
||||
def test_router_queue_coalesces_requests_for_same_model():
|
||||
"""many requests for one missing model share a slot, so only one model is given up"""
|
||||
global server
|
||||
server.models_max = 2
|
||||
server.start()
|
||||
|
||||
_load_model_and_wait(MODEL_A, timeout=120)
|
||||
_load_model_and_wait(MODEL_B, timeout=120)
|
||||
|
||||
# keep MODEL_A busy so MODEL_B is the only model that can be given up
|
||||
busy = _Bg(lambda: _tokenize(MODEL_A)).start()
|
||||
time.sleep(0.5)
|
||||
|
||||
waiters = [_Bg(lambda: _tokenize(MODEL_C)).start() for _ in range(3)]
|
||||
|
||||
busy.join()
|
||||
for w in waiters:
|
||||
w.join()
|
||||
|
||||
busy.assert_ok("request against the busy model")
|
||||
for i, w in enumerate(waiters):
|
||||
w.assert_ok(f"queued request {i}")
|
||||
|
||||
_wait_for_model_status(MODEL_C, {"loaded"}, timeout=120)
|
||||
# one entry for 3 requests means one eviction: MODEL_B goes, MODEL_A is left alone.
|
||||
# without coalescing the leftover entries still ask for a slot,
|
||||
# and MODEL_A is taken too as soon as it goes idle
|
||||
assert _get_model_status(MODEL_A) == "loaded"
|
||||
assert _get_model_status(MODEL_B) == "unloaded"
|
||||
|
||||
|
||||
def test_router_queue_client_disconnect_keeps_model():
|
||||
"""a client that leaves while queued must not cost a running model its slot"""
|
||||
global server
|
||||
server.models_max = 1
|
||||
server.start()
|
||||
|
||||
_load_model_and_wait(MODEL_A, timeout=120)
|
||||
|
||||
busy = _Bg(lambda: _tokenize(MODEL_A)).start()
|
||||
time.sleep(0.5)
|
||||
|
||||
# queues behind MODEL_A, then gives up long before MODEL_A goes idle
|
||||
with pytest.raises(requests.exceptions.RequestException):
|
||||
_tokenize(MODEL_B, timeout=1)
|
||||
|
||||
busy.join()
|
||||
busy.assert_ok("request against the busy model")
|
||||
|
||||
# nobody is waiting anymore, so MODEL_A keeps its slot
|
||||
time.sleep(3)
|
||||
assert _get_model_status(MODEL_A) == "loaded"
|
||||
assert _get_model_status(MODEL_B) == "unloaded"
|
||||
|
||||
|
||||
def test_router_queue_is_fifo():
|
||||
"""the queue is served in arrival order"""
|
||||
global server
|
||||
server.models_max = 1
|
||||
server.start()
|
||||
|
||||
_load_model_and_wait(MODEL_A, timeout=120)
|
||||
|
||||
busy = _Bg(lambda: _tokenize(MODEL_A)).start()
|
||||
time.sleep(0.5)
|
||||
|
||||
first = _Bg(lambda: _tokenize(MODEL_B)).start()
|
||||
time.sleep(1) # keep the arrival order unambiguous
|
||||
second = _Bg(lambda: _tokenize(MODEL_C)).start()
|
||||
|
||||
busy.join()
|
||||
first.join()
|
||||
second.join()
|
||||
|
||||
busy.assert_ok("request against the busy model")
|
||||
first.assert_ok("first queued request")
|
||||
second.assert_ok("second queued request")
|
||||
|
||||
assert first.done_at < second.done_at, "queue was not served in arrival order"
|
||||
|
||||
|
||||
def test_router_no_models_autoload():
|
||||
global server
|
||||
server.no_models_autoload = True
|
||||
|
||||
@@ -132,7 +132,10 @@ class ServerProcess:
|
||||
self.external_server = "DEBUG_EXTERNAL" in os.environ
|
||||
|
||||
def start(self, timeout_seconds: int = DEFAULT_HTTP_TIMEOUT) -> None:
|
||||
env = {**os.environ}
|
||||
env = {
|
||||
**os.environ,
|
||||
"LLAMA_SERVER_DEBUG_FAKE_TIMING": "1",
|
||||
}
|
||||
if "LLAMA_CACHE" not in os.environ:
|
||||
env["LLAMA_CACHE"] = "tmp"
|
||||
if self.external_server:
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
engine-strict=true
|
||||
ignore-scripts=true
|
||||
min-release-age=7
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
SETTING_CONFIG_DEFAULT,
|
||||
INITIAL_FILE_SIZE,
|
||||
PROMPT_CONTENT_SEPARATOR,
|
||||
PROMPT_TRIGGER_PREFIX,
|
||||
RESOURCE_TRIGGER_PREFIX
|
||||
PROMPT_TRIGGER_PREFIX
|
||||
} from '$lib/constants';
|
||||
import {
|
||||
ContentPartType,
|
||||
@@ -39,8 +38,23 @@
|
||||
activeConversation,
|
||||
pendingCwd
|
||||
} from '$lib/stores/conversations.svelte';
|
||||
import type { GetPromptResult, MCPPromptInfo, MCPResourceInfo, PromptMessage } from '$lib/types';
|
||||
import { isIMEComposing, parseClipboardContent, uuid } from '$lib/utils';
|
||||
import type {
|
||||
FileMentionEntry,
|
||||
GetPromptResult,
|
||||
MCPPromptInfo,
|
||||
MCPResourceInfo,
|
||||
PromptMessage
|
||||
} from '$lib/types';
|
||||
import {
|
||||
buildMentionInsertion,
|
||||
findMentionToken,
|
||||
isIMEComposing,
|
||||
mentionLinkEndingAt,
|
||||
parseClipboardContent,
|
||||
takeMentionDismissSnapshot,
|
||||
type MentionDismissSnapshot,
|
||||
uuid
|
||||
} from '$lib/utils';
|
||||
import {
|
||||
AudioRecorder,
|
||||
convertToWav,
|
||||
@@ -108,11 +122,18 @@
|
||||
let isRecording = $state(false);
|
||||
let recordingSupported = $state(false);
|
||||
|
||||
// Invisible anchor at the form's top edge so the mention popover floats above the box.
|
||||
let mentionAnchor: HTMLDivElement | null = $state(null);
|
||||
|
||||
// Picker State
|
||||
let isPromptPickerOpen = $state(false);
|
||||
let promptSearchQuery = $state('');
|
||||
let isInlineResourcePickerOpen = $state(false);
|
||||
let resourceSearchQuery = $state('');
|
||||
let isMentionPickerOpen = $state(false);
|
||||
let mentionQuery = $state('');
|
||||
|
||||
// Last dismissed `@`-mention token; while intact the picker does not
|
||||
// reopen, so an escaped `@<query>` stays literal until edited.
|
||||
let mentionDismissedSnapshot: MentionDismissSnapshot | null = null;
|
||||
|
||||
let cwd = $derived(activeConversation()?.cwd ?? pendingCwd());
|
||||
|
||||
@@ -219,26 +240,44 @@
|
||||
function handleInput() {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
|
||||
const cursor = textareaRef?.getCaretOffset() ?? value.length;
|
||||
const mentionToken = findMentionToken(value, cursor);
|
||||
|
||||
// A `@` mention takes precedence; typing one switches from any other open picker.
|
||||
if (mentionToken && mentionToken.query.length > 0) {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
|
||||
const isDismissedSticky =
|
||||
mentionDismissedSnapshot !== null &&
|
||||
mentionDismissedSnapshot.start === mentionToken.start &&
|
||||
mentionDismissedSnapshot.query === mentionToken.query;
|
||||
|
||||
if (!isDismissedSticky) {
|
||||
mentionDismissedSnapshot = null;
|
||||
isMentionPickerOpen = true;
|
||||
mentionQuery = mentionToken.query;
|
||||
return;
|
||||
}
|
||||
|
||||
isMentionPickerOpen = false;
|
||||
mentionQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
isMentionPickerOpen = false;
|
||||
mentionQuery = '';
|
||||
// Token gone or changed: reset the snapshot so a fresh `@` reopens.
|
||||
if (mentionDismissedSnapshot !== null && !mentionToken) {
|
||||
mentionDismissedSnapshot = null;
|
||||
}
|
||||
|
||||
if (value.startsWith(PROMPT_TRIGGER_PREFIX) && hasServers) {
|
||||
isPromptPickerOpen = true;
|
||||
promptSearchQuery = value.slice(1);
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
} else if (
|
||||
value.startsWith(RESOURCE_TRIGGER_PREFIX) &&
|
||||
hasServers &&
|
||||
mcpStore.hasResourcesCapability(perChatOverrides)
|
||||
) {
|
||||
isInlineResourcePickerOpen = true;
|
||||
resourceSearchQuery = value.slice(1);
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
} else {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,15 +286,30 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace at a mention link's end deletes the whole token at once.
|
||||
if (event.key === KeyboardKey.BACKSPACE && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
const el = textareaRef?.getElement();
|
||||
if (el instanceof HTMLTextAreaElement && el.selectionStart === el.selectionEnd) {
|
||||
const link = mentionLinkEndingAt(value, el.selectionStart);
|
||||
if (link) {
|
||||
event.preventDefault();
|
||||
value = value.slice(0, link.start) + value.slice(link.end);
|
||||
onValueChange?.(value);
|
||||
queueMicrotask(() => textareaRef?.setCaretOffset(link.start));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE && isInlineResourcePickerOpen) {
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
if (event.key === KeyboardKey.ESCAPE && isMentionPickerOpen) {
|
||||
isMentionPickerOpen = false;
|
||||
mentionQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -432,33 +486,33 @@
|
||||
textareaRef?.focus();
|
||||
}
|
||||
|
||||
function handleInlineResourcePickerClose() {
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
textareaRef?.focus();
|
||||
function handleMentionPickerClose() {
|
||||
if (isMentionPickerOpen) {
|
||||
const cursor = textareaRef?.getCaretOffset() ?? value.length;
|
||||
mentionDismissedSnapshot = takeMentionDismissSnapshot(value, cursor);
|
||||
}
|
||||
isMentionPickerOpen = false;
|
||||
mentionQuery = '';
|
||||
refocusInput();
|
||||
}
|
||||
|
||||
function handleInlineResourceSelect() {
|
||||
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
}
|
||||
// Splice the `[name](file:///<abs path>)` link in place of the `@<query>`
|
||||
// token, restoring the caret after the bindable value settles.
|
||||
function handleMentionSelect(entry: FileMentionEntry) {
|
||||
const cursor = textareaRef?.getCaretOffset() ?? value.length;
|
||||
const token = findMentionToken(value, cursor);
|
||||
if (!token) return;
|
||||
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
textareaRef?.focus();
|
||||
}
|
||||
const built = buildMentionInsertion(entry, value, token);
|
||||
if (!built) return;
|
||||
|
||||
function handleBrowseResources() {
|
||||
isInlineResourcePickerOpen = false;
|
||||
resourceSearchQuery = '';
|
||||
value = built.newValue;
|
||||
onValueChange?.(built.newValue);
|
||||
|
||||
if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) {
|
||||
value = '';
|
||||
onValueChange?.('');
|
||||
}
|
||||
|
||||
isResourceDialogOpen = true;
|
||||
queueMicrotask(() => {
|
||||
textareaRef?.focus();
|
||||
textareaRef?.setCaretOffset(built.caretOffset);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMicClick() {
|
||||
@@ -505,17 +559,25 @@
|
||||
bind:this={pickersRef}
|
||||
{isPromptPickerOpen}
|
||||
{promptSearchQuery}
|
||||
{isInlineResourcePickerOpen}
|
||||
{resourceSearchQuery}
|
||||
{isMentionPickerOpen}
|
||||
{mentionQuery}
|
||||
{mentionAnchor}
|
||||
scopePath={cwd}
|
||||
onPromptPickerClose={handlePromptPickerClose}
|
||||
onInlineResourcePickerClose={handleInlineResourcePickerClose}
|
||||
onInlineResourceSelect={handleInlineResourceSelect}
|
||||
onMentionPickerClose={handleMentionPickerClose}
|
||||
onMentionOpened={() => textareaRef?.focus()}
|
||||
onMentionSelect={handleMentionSelect}
|
||||
onPromptLoadStart={handlePromptLoadStart}
|
||||
onPromptLoadComplete={handlePromptLoadComplete}
|
||||
onPromptLoadError={handlePromptLoadError}
|
||||
onInlineResourceBrowse={handleBrowseResources}
|
||||
/>
|
||||
|
||||
<div
|
||||
bind:this={mentionAnchor}
|
||||
class="pointer-events-none absolute top-0 right-0 left-0 h-px"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="{INPUT_CLASSES} overflow-hidden rounded-4xl md:rounded-3xl backdrop-blur-md {disabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
<script lang="ts">
|
||||
import { File, Folder } from '@lucide/svelte';
|
||||
import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { BuiltInTool, FileMentionEntryType, GlobSearchType } from '$lib/enums';
|
||||
import { isMobile } from '$lib/stores/viewport.svelte';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte';
|
||||
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
|
||||
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
|
||||
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
||||
import type { FileMentionEntry } from '$lib/types';
|
||||
import {
|
||||
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
|
||||
HOME_TILDE,
|
||||
SEARCH_DEBOUNCE_MS
|
||||
} from '$lib/constants';
|
||||
|
||||
/**
|
||||
* Floating file/folder mention picker. The chat input is the search
|
||||
* surface: `query` (typed after `@`) drives a `file_glob_search` tool
|
||||
* call scoped to `scopePath`. The parent owns the "dismissed token,
|
||||
* don't re-open until it changes" snapshot.
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
customAnchor?: HTMLElement | null;
|
||||
scopePath?: string | null;
|
||||
onClose: () => void;
|
||||
onSelect: (entry: FileMentionEntry) => void;
|
||||
/** Fired when `isOpen` becomes true, so the host can keep focus on the chat input. */
|
||||
onOpened?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
isOpen,
|
||||
query,
|
||||
customAnchor = null,
|
||||
scopePath = null,
|
||||
onClose,
|
||||
onSelect,
|
||||
onOpened
|
||||
}: Props = $props();
|
||||
|
||||
const nav = usePickerNavigation({
|
||||
isOpen: () => isOpen,
|
||||
count: () => displayedItems.length,
|
||||
onClose: () => onClose(),
|
||||
onSelect: (index) => handleSelect(displayedItems[index])
|
||||
});
|
||||
|
||||
// When the server does not expose file_glob_search (started without
|
||||
// --tools) or the user disabled it, the picker still opens but explains
|
||||
// why instead of firing searches that would only fail.
|
||||
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.FILE_GLOB_SEARCH));
|
||||
const fileSearchEnabled = $derived(
|
||||
fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
|
||||
);
|
||||
|
||||
let searchResults = $state<FileMentionEntry[]>([]);
|
||||
let searchError = $state<string | null>(null);
|
||||
|
||||
// Coerce the depth setting to a positive integer; an invalid value
|
||||
// would otherwise reach the server as max_depth 0 = unlimited.
|
||||
const searchDepth = $derived.by(() => {
|
||||
const n = Number(config().mentionSearchMaxDepth);
|
||||
return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH;
|
||||
});
|
||||
|
||||
const home = $derived(toolsStore.serverHome);
|
||||
|
||||
// A smaller window than the WD picker suffices: entries are ranked client-side.
|
||||
const MENTION_SEARCH_LIMIT = 50;
|
||||
|
||||
const search = useDebouncedSearch({
|
||||
debounceMs: SEARCH_DEBOUNCE_MS,
|
||||
canRun: () => isOpen && fileSearchEnabled,
|
||||
getQuery: () => trimmedQuery,
|
||||
run: async (query, signal, isCurrent) => {
|
||||
try {
|
||||
// A trailing path separator targets a directory, so also list its
|
||||
// children. Accept both `/` and `\`.
|
||||
const res = await runGlobSearchWithChildren(
|
||||
query,
|
||||
scopePath ?? home ?? HOME_TILDE,
|
||||
searchDepth,
|
||||
MENTION_SEARCH_LIMIT,
|
||||
signal,
|
||||
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
|
||||
);
|
||||
if (!isCurrent()) return;
|
||||
if (res.error) {
|
||||
searchResults = [];
|
||||
searchError = res.error;
|
||||
return;
|
||||
}
|
||||
const toEntry = (e: GlobEntryResult): FileMentionEntry => ({
|
||||
path: e.path,
|
||||
name: e.name,
|
||||
type: e.type === 'dir' ? FileMentionEntryType.DIRECTORY : FileMentionEntryType.FILE
|
||||
});
|
||||
searchResults = res.entries.map(toEntry);
|
||||
searchError = null;
|
||||
} catch (err) {
|
||||
if (!isCurrent() || signal.aborted) return;
|
||||
searchResults = [];
|
||||
searchError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const trimmedQuery = $derived((query ?? '').trim());
|
||||
const displayedItems = $derived(searchResults);
|
||||
|
||||
const emptyMessage = $derived.by(() => {
|
||||
if (fileSearchKey === null) {
|
||||
return 'File search is unavailable on this server (started without --tools)';
|
||||
}
|
||||
if (!fileSearchEnabled) {
|
||||
return 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions';
|
||||
}
|
||||
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
|
||||
});
|
||||
|
||||
const showTooltip = $derived(!isMobile.current);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
void toolsStore.resolveServerHome();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
nav.reset(0);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) onOpened?.();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const q = (query ?? '').trim();
|
||||
if (!isOpen || !q || !fileSearchEnabled) {
|
||||
search.cancel();
|
||||
searchResults = [];
|
||||
searchError = null;
|
||||
return;
|
||||
}
|
||||
search.setLoading(true);
|
||||
search.run(q);
|
||||
});
|
||||
|
||||
function handleSelect(entry: FileMentionEntry) {
|
||||
onSelect(entry);
|
||||
onClose();
|
||||
}
|
||||
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
return nav.handleKeydown(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<!-- Invisible form-wide trigger: stops bits-ui's outside-click detector
|
||||
from closing the picker when the user clicks inside the textarea.
|
||||
We open programmatically via `open={isOpen}`, so it is inert
|
||||
(tabindex=-1 + pointer-events-none + opacity-0 + aria-hidden).
|
||||
Positioning comes from `customAnchor` at the form's top edge. -->
|
||||
<Popover.Trigger
|
||||
class="pointer-events-none absolute inset-0 opacity-0"
|
||||
tabindex={-1}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="sr-only">Open file mention picker</span>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={12}
|
||||
{customAnchor}
|
||||
preventScroll={false}
|
||||
onkeydown={handleKeydown}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
class={[
|
||||
'w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl',
|
||||
className
|
||||
]}
|
||||
>
|
||||
<ChatFormPickerList
|
||||
items={displayedItems}
|
||||
isLoading={search.isSearching}
|
||||
selectedIndex={nav.hoveredIndex}
|
||||
showSearchInput={false}
|
||||
searchQuery={query ?? ''}
|
||||
{emptyMessage}
|
||||
itemKey={(entry) => entry.type + ':' + entry.path}
|
||||
scrollTrigger={nav.scrollTrigger}
|
||||
>
|
||||
{#snippet item(entry, index, isSelected)}
|
||||
<ChatFormPickerListItem
|
||||
dataIndex={index}
|
||||
{isSelected}
|
||||
onclick={() => handleSelect(entry)}
|
||||
onmouseenter={() => nav.setHover(index)}
|
||||
>
|
||||
{@const Icon = entry.type === FileMentionEntryType.DIRECTORY ? Folder : File}
|
||||
<Icon
|
||||
class={[
|
||||
'mt-0.5 h-4 w-4 shrink-0',
|
||||
entry.type === FileMentionEntryType.DIRECTORY
|
||||
? 'text-amber-500'
|
||||
: 'text-muted-foreground'
|
||||
]}
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
{#if showTooltip}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<span {...props} class="truncate text-sm font-medium">{entry.name}</span>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{entry.path}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{:else}
|
||||
<span class="truncate text-sm font-medium">{entry.name}</span>
|
||||
{/if}
|
||||
<span
|
||||
class="shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{entry.type}
|
||||
</span>
|
||||
</div>
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-left text-xs">
|
||||
<HighlightedMatch text={abbreviateHome(entry.path, home)} query={trimmedQuery} />
|
||||
</span>
|
||||
</div>
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
+51
-22
@@ -2,6 +2,7 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
|
||||
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
|
||||
import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants';
|
||||
|
||||
interface Props {
|
||||
@@ -11,11 +12,19 @@
|
||||
searchQuery: string;
|
||||
showSearchInput: boolean;
|
||||
searchPlaceholder?: string;
|
||||
// Omit to distinguish "haven't searched yet" from "search returned nothing".
|
||||
emptyMessage?: string;
|
||||
autofocus?: boolean;
|
||||
inputRef?: HTMLInputElement | null;
|
||||
onSearchClose?: () => void;
|
||||
itemKey: (item: T, index: number) => string;
|
||||
item: Snippet<[T, number, boolean]>;
|
||||
skeleton?: Snippet;
|
||||
skeletonCount?: number;
|
||||
footer?: Snippet;
|
||||
// Counter bumped by the picker on keyboard nav; scrolls the selected
|
||||
// row into view without scrolling on hover or result replacement.
|
||||
scrollTrigger?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -25,49 +34,69 @@
|
||||
searchQuery = $bindable(),
|
||||
showSearchInput,
|
||||
searchPlaceholder = 'Search...',
|
||||
emptyMessage = 'No items available',
|
||||
emptyMessage,
|
||||
autofocus = false,
|
||||
inputRef = $bindable(null),
|
||||
onSearchClose,
|
||||
itemKey,
|
||||
item,
|
||||
skeleton,
|
||||
footer
|
||||
skeletonCount = 6,
|
||||
footer,
|
||||
scrollTrigger
|
||||
}: Props = $props();
|
||||
|
||||
let listContainer = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (listContainer && selectedIndex >= 0 && selectedIndex < items.length) {
|
||||
const selectedElement = listContainer.querySelector(
|
||||
`[data-picker-index="${selectedIndex}"]`
|
||||
) as HTMLElement;
|
||||
let listPaddingTop = $derived(
|
||||
showSearchInput ? (isLoading || items.length > 0 ? 'pt-13' : 'pt-10') : ''
|
||||
);
|
||||
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
}
|
||||
// selectedIndex/items.length are untracked so hover and result replacement
|
||||
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger.
|
||||
useScrollActiveRow({
|
||||
getTrigger: () => scrollTrigger,
|
||||
getContainer: () => listContainer,
|
||||
getIndex: () => selectedIndex,
|
||||
getCount: () => items.length,
|
||||
dataIndex: 'picker'
|
||||
});
|
||||
</script>
|
||||
|
||||
<ScrollArea>
|
||||
{#if showSearchInput}
|
||||
<div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
|
||||
<SearchInput placeholder={searchPlaceholder} bind:value={searchQuery} />
|
||||
<SearchInput
|
||||
{autofocus}
|
||||
placeholder={searchPlaceholder}
|
||||
bind:value={searchQuery}
|
||||
bind:ref={inputRef}
|
||||
onClose={onSearchClose}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
bind:this={listContainer}
|
||||
class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, showSearchInput && 'pt-13']}
|
||||
>
|
||||
<div bind:this={listContainer} class={[`${CHAT_FORM_POPOVER_MAX_HEIGHT} p-2`, listPaddingTop]}>
|
||||
{#if isLoading}
|
||||
{#if skeleton}
|
||||
{@render skeleton()}
|
||||
{:else}
|
||||
<div aria-busy="true" aria-live="polite" class="flex flex-col">
|
||||
{#each { length: skeletonCount } as _, rowIndex (rowIndex)}
|
||||
<div class="flex items-start gap-3 rounded-lg px-3 py-2">
|
||||
<div class="mt-0.5 size-4 shrink-0 animate-pulse rounded-md bg-muted/60"></div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="h-5 w-2/5 animate-pulse rounded-sm bg-muted/60"></div>
|
||||
<div class="h-4 w-1/3 animate-pulse rounded-sm bg-muted/40"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if items && items.length === 0}
|
||||
{#if emptyMessage}
|
||||
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
{/if}
|
||||
{:else if items.length === 0}
|
||||
<div class="py-6 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
{:else}
|
||||
{#each items as itemData, index (itemKey(itemData, index))}
|
||||
{@render item(itemData, index, index === selectedIndex)}
|
||||
|
||||
+15
-2
@@ -3,21 +3,34 @@
|
||||
|
||||
interface Props {
|
||||
isSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
onclick: () => void;
|
||||
onmouseenter?: () => void;
|
||||
dataIndex?: number;
|
||||
children: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { isSelected = false, onclick, dataIndex, children }: Props = $props();
|
||||
let {
|
||||
class: className = '',
|
||||
isSelected = false,
|
||||
disabled = false,
|
||||
onclick,
|
||||
onmouseenter,
|
||||
dataIndex,
|
||||
children
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-picker-index={dataIndex}
|
||||
{disabled}
|
||||
{onclick}
|
||||
{onmouseenter}
|
||||
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
|
||||
? 'bg-accent/50'
|
||||
: ''}"
|
||||
: ''} {disabled ? 'cursor-not-allowed opacity-50' : ''} {className}"
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
+1
@@ -42,6 +42,7 @@
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
|
||||
preventScroll={false}
|
||||
onkeydown={onKeydown}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
import type { MCPResourceInfo, MCPServerSettingsEntry } from '$lib/types';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { FolderOpen } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import {
|
||||
ChatFormPickerPopover,
|
||||
ChatFormPickerList,
|
||||
ChatFormPickerListItem,
|
||||
ChatFormPickerItemHeader,
|
||||
ChatFormPickerListItemSkeleton
|
||||
} from '$lib/components/app/chat';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
isOpen?: boolean;
|
||||
searchQuery?: string;
|
||||
onClose?: () => void;
|
||||
onResourceSelect?: (resource: MCPResourceInfo) => void;
|
||||
onBrowse?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
isOpen = false,
|
||||
searchQuery = '',
|
||||
onClose,
|
||||
onResourceSelect,
|
||||
onBrowse
|
||||
}: Props = $props();
|
||||
|
||||
let resources = $state<MCPResourceInfo[]>([]);
|
||||
let isLoading = $state(false);
|
||||
let selectedIndex = $state(0);
|
||||
let internalSearchQuery = $state('');
|
||||
|
||||
let serverSettingsMap = $derived.by(() => {
|
||||
const servers = mcpStore.getServers();
|
||||
const map = new SvelteMap<string, MCPServerSettingsEntry>();
|
||||
|
||||
for (const server of servers) {
|
||||
map.set(server.id, server);
|
||||
}
|
||||
|
||||
return map;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
loadResources();
|
||||
selectedIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (filteredResources.length > 0 && selectedIndex >= filteredResources.length) {
|
||||
selectedIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadResources() {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
|
||||
|
||||
if (!initialized) {
|
||||
resources = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await mcpStore.fetchAllResources();
|
||||
resources = mcpResourceStore.getAllResourceInfos();
|
||||
} catch (error) {
|
||||
console.error('[ChatFormPickerMcpResources] Failed to load resources:', error);
|
||||
resources = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleResourceClick(resource: MCPResourceInfo) {
|
||||
mcpStore.attachResource(resource.uri);
|
||||
|
||||
onResourceSelect?.(resource);
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function isResourceAttached(uri: string): boolean {
|
||||
return mcpResourceStore.isAttached(uri);
|
||||
}
|
||||
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (!isOpen) return false;
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
onClose?.();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
|
||||
if (filteredResources.length > 0) {
|
||||
selectedIndex = (selectedIndex + 1) % filteredResources.length;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
if (filteredResources.length > 0) {
|
||||
selectedIndex = selectedIndex === 0 ? filteredResources.length - 1 : selectedIndex - 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
if (filteredResources[selectedIndex]) {
|
||||
handleResourceClick(filteredResources[selectedIndex]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
let filteredResources = $derived.by(() => {
|
||||
const sortedServers = mcpStore.getServers();
|
||||
const serverOrderMap = new Map(sortedServers.map((server, index) => [server.id, index]));
|
||||
|
||||
const sortedResources = [...resources].sort((a, b) => {
|
||||
const orderA = serverOrderMap.get(a.serverName) ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = serverOrderMap.get(b.serverName) ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const query = (searchQuery || internalSearchQuery).toLowerCase();
|
||||
if (!query) return sortedResources;
|
||||
|
||||
return sortedResources.filter(
|
||||
(resource) =>
|
||||
resource.name.toLowerCase().includes(query) ||
|
||||
resource.title?.toLowerCase().includes(query) ||
|
||||
resource.description?.toLowerCase().includes(query) ||
|
||||
resource.uri.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
let showSearchInput = $derived(resources.length > 3);
|
||||
</script>
|
||||
|
||||
<ChatFormPickerPopover
|
||||
bind:isOpen
|
||||
class={className}
|
||||
srLabel="Open resource picker"
|
||||
{onClose}
|
||||
onKeydown={handleKeydown}
|
||||
>
|
||||
<ChatFormPickerList
|
||||
items={filteredResources}
|
||||
{isLoading}
|
||||
{selectedIndex}
|
||||
bind:searchQuery={internalSearchQuery}
|
||||
{showSearchInput}
|
||||
searchPlaceholder="Search resources..."
|
||||
emptyMessage="No MCP resources available"
|
||||
itemKey={(resource) => resource.serverName + ':' + resource.uri}
|
||||
>
|
||||
{#snippet item(resource, index, isSelected)}
|
||||
{@const server = serverSettingsMap.get(resource.serverName)}
|
||||
{@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName}
|
||||
|
||||
<ChatFormPickerListItem
|
||||
dataIndex={index}
|
||||
{isSelected}
|
||||
onclick={() => handleResourceClick(resource)}
|
||||
>
|
||||
<ChatFormPickerItemHeader
|
||||
{server}
|
||||
{serverLabel}
|
||||
title={resource.title || resource.name}
|
||||
description={resource.description}
|
||||
>
|
||||
{#snippet titleExtra()}
|
||||
{#if isResourceAttached(resource.uri)}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
|
||||
>
|
||||
attached
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet subtitle()}
|
||||
<p class="mt-0.5 truncate text-xs text-muted-foreground/60">
|
||||
{resource.uri}
|
||||
</p>
|
||||
{/snippet}
|
||||
</ChatFormPickerItemHeader>
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
|
||||
{#snippet skeleton()}
|
||||
<ChatFormPickerListItemSkeleton />
|
||||
{/snippet}
|
||||
|
||||
{#snippet footer()}
|
||||
{#if onBrowse && resources.length > 3}
|
||||
<Button
|
||||
class="fixed right-3 bottom-3"
|
||||
type="button"
|
||||
onclick={onBrowse}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<FolderOpen class="h-3 w-3" />
|
||||
|
||||
Browse all
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</ChatFormPickerPopover>
|
||||
+28
-22
@@ -1,16 +1,19 @@
|
||||
<script lang="ts">
|
||||
import ChatFormMentionPicker from './ChatFormMentionPicker.svelte';
|
||||
import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
|
||||
import ChatFormPickerMcpResources from './ChatFormPickerMcpResources.svelte';
|
||||
import type { GetPromptResult, MCPPromptInfo } from '$lib/types';
|
||||
import type { FileMentionEntry, GetPromptResult, MCPPromptInfo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
isPromptPickerOpen?: boolean;
|
||||
promptSearchQuery?: string;
|
||||
isInlineResourcePickerOpen?: boolean;
|
||||
resourceSearchQuery?: string;
|
||||
isMentionPickerOpen?: boolean;
|
||||
mentionQuery?: string;
|
||||
mentionAnchor?: HTMLElement | null;
|
||||
scopePath?: string | null;
|
||||
onPromptPickerClose?: () => void;
|
||||
onInlineResourcePickerClose?: () => void;
|
||||
onInlineResourceSelect?: () => void;
|
||||
onMentionPickerClose?: () => void;
|
||||
onMentionOpened?: () => void;
|
||||
onMentionSelect?: (entry: FileMentionEntry) => void;
|
||||
onPromptLoadStart?: (
|
||||
placeholderId: string,
|
||||
promptInfo: MCPPromptInfo,
|
||||
@@ -18,25 +21,26 @@
|
||||
) => void;
|
||||
onPromptLoadComplete?: (placeholderId: string, result: GetPromptResult) => void;
|
||||
onPromptLoadError?: (placeholderId: string, error: string) => void;
|
||||
onInlineResourceBrowse?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isPromptPickerOpen,
|
||||
promptSearchQuery,
|
||||
isInlineResourcePickerOpen,
|
||||
resourceSearchQuery,
|
||||
isMentionPickerOpen,
|
||||
mentionQuery,
|
||||
mentionAnchor,
|
||||
scopePath,
|
||||
onPromptPickerClose,
|
||||
onInlineResourcePickerClose,
|
||||
onInlineResourceSelect,
|
||||
onMentionPickerClose,
|
||||
onMentionOpened,
|
||||
onMentionSelect,
|
||||
onPromptLoadStart,
|
||||
onPromptLoadComplete,
|
||||
onPromptLoadError,
|
||||
onInlineResourceBrowse
|
||||
onPromptLoadError
|
||||
}: Props = $props();
|
||||
|
||||
let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
|
||||
let resourcePickerRef: ChatFormPickerMcpResources | undefined = $state(undefined);
|
||||
let mentionPickerRef: ChatFormMentionPicker | undefined = $state(undefined);
|
||||
|
||||
/**
|
||||
* Delegates keyboard events to the active picker child.
|
||||
@@ -47,7 +51,7 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isInlineResourcePickerOpen && resourcePickerRef?.handleKeydown(event)) {
|
||||
if (isMentionPickerOpen && mentionPickerRef?.handleKeydown(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -65,11 +69,13 @@
|
||||
{onPromptLoadError}
|
||||
/>
|
||||
|
||||
<ChatFormPickerMcpResources
|
||||
bind:this={resourcePickerRef}
|
||||
isOpen={isInlineResourcePickerOpen}
|
||||
searchQuery={resourceSearchQuery}
|
||||
onClose={onInlineResourcePickerClose}
|
||||
onResourceSelect={onInlineResourceSelect}
|
||||
onBrowse={onInlineResourceBrowse}
|
||||
<ChatFormMentionPicker
|
||||
bind:this={mentionPickerRef}
|
||||
isOpen={isMentionPickerOpen ?? false}
|
||||
query={mentionQuery ?? ''}
|
||||
customAnchor={mentionAnchor}
|
||||
scopePath={scopePath ?? null}
|
||||
onClose={onMentionPickerClose ?? (() => {})}
|
||||
onOpened={onMentionOpened}
|
||||
onSelect={onMentionSelect ?? (() => {})}
|
||||
/>
|
||||
|
||||
@@ -48,6 +48,16 @@
|
||||
textareaElement.style.height = '1rem';
|
||||
}
|
||||
}
|
||||
|
||||
// Plain-text caret offsets for the mention-splice flow.
|
||||
export function getCaretOffset(): number {
|
||||
if (!textareaElement) return 0;
|
||||
return textareaElement.selectionStart ?? textareaElement.value.length;
|
||||
}
|
||||
|
||||
export function setCaretOffset(offset: number) {
|
||||
textareaElement?.setSelectionRange(offset, offset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex-1 {className}">
|
||||
|
||||
@@ -351,14 +351,14 @@ export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/Cha
|
||||
* Generic scrollable list for picker popovers. Provides search input,
|
||||
* scroll-into-view for keyboard navigation, loading skeletons, empty state,
|
||||
* and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering.
|
||||
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
|
||||
* Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
|
||||
*/
|
||||
export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte';
|
||||
|
||||
/**
|
||||
* Generic button wrapper for picker list items. Provides consistent styling,
|
||||
* hover/selected states, and data-picker-index attribute for scroll-into-view.
|
||||
* Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources.
|
||||
* Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker.
|
||||
*/
|
||||
export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte';
|
||||
|
||||
@@ -376,30 +376,19 @@ export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPickers/
|
||||
export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte';
|
||||
|
||||
/**
|
||||
* **ChatFormPickerMcpResources** - MCP resource selection interface
|
||||
*
|
||||
* Floating picker for browsing and attaching MCP Server Resources.
|
||||
* Triggered by typing `@` in the chat input.
|
||||
* Loads resources from connected MCP servers and allows users to attach them to the chat context.
|
||||
*
|
||||
* **Features:**
|
||||
* - Search/filter resources by name, title, description, or URI across all connected servers
|
||||
* - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close)
|
||||
* - Shows attached state for already-attached resources
|
||||
* - Loading states with skeleton placeholders
|
||||
* - Server information header per resource for visual identification
|
||||
*
|
||||
* **Exported API:**
|
||||
* - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled
|
||||
* `@`-triggered file/folder mention picker. Resolves `@<query>` in the chat
|
||||
* input to a filesystem match via the server's `file_glob_search` built-in
|
||||
* tool, scoped to the conversation cwd (or server home when unset).
|
||||
* Selection splices a `[name](file:///<abs path>)` link into the input.
|
||||
*/
|
||||
export { default as ChatFormPickerMcpResources } from './ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte';
|
||||
export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
|
||||
|
||||
/**
|
||||
* **ChatFormPickers** - Chat input picker container
|
||||
*
|
||||
* Container component that hosts both MCP prompt and MCP resource pickers.
|
||||
* Container component that hosts the MCP prompt and file mention pickers.
|
||||
* Manages shared state, keyboard navigation, and coordination between the two
|
||||
* picker interfaces. Used within ChatForm for `@`-triggered pickers.
|
||||
* picker interfaces. Used within ChatForm.
|
||||
*/
|
||||
export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte';
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer';
|
||||
import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links';
|
||||
import { rehypeFileBadge } from './plugins/rehype/file-badge';
|
||||
import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks';
|
||||
import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks';
|
||||
import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre';
|
||||
@@ -174,6 +175,7 @@
|
||||
}) // Add syntax highlighting
|
||||
.use(rehypeRestoreTableHtml) // Restore limited HTML (e.g., <br>, <ul>) inside Markdown tables
|
||||
.use(rehypeEnhanceLinks) // Add target="_blank" to links
|
||||
.use(rehypeFileBadge) // Render file:// anchors as inline badge chips
|
||||
.use(rehypeMermaidPre) // Convert mermaid blocks to <pre class="mermaid">
|
||||
.use(rehypeSvgPre) // Convert svg blocks to <pre class="svg-block">
|
||||
.use(rehypeEnhanceCodeBlocks) // Wrap code blocks with header and actions
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Rehype plugin that rewrites `file://` markdown anchors into the inline
|
||||
* @-mention chip, reusing the visual contract from
|
||||
* `$lib/constants/mention-badge`.
|
||||
*
|
||||
* The chip is presentational: `file://` navigation is blocked from
|
||||
* http(s) pages, so the anchor becomes a plain `<span>` (no link role,
|
||||
* no tab stop); the full path stays available on `title`.
|
||||
*/
|
||||
|
||||
import { decodeFileLinkPath, getMentionBadgeIconPaths, getMentionBadgeLabel } from '$lib/utils';
|
||||
import {
|
||||
FILE_URI_PREFIX,
|
||||
MENTION_BADGE_CLASSNAME,
|
||||
MENTION_BADGE_ICON_CLASSNAME,
|
||||
MENTION_BADGE_SVG_ATTRIBUTES,
|
||||
PATH_SEPARATOR,
|
||||
SETTINGS_KEYS
|
||||
} from '$lib/constants';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import type { Plugin } from 'unified';
|
||||
import type { Root, Element } from 'hast';
|
||||
import { visit } from 'unist-util-visit';
|
||||
|
||||
// Trailing path separators mark a directory and are kept out of the label.
|
||||
const TRAILING_SEPARATOR_REGEX = /\/+$/;
|
||||
|
||||
function decodeHrefPath(href: string): string {
|
||||
const stripped = href.startsWith(FILE_URI_PREFIX) ? href.slice(FILE_URI_PREFIX.length) : href;
|
||||
return decodeFileLinkPath(stripped);
|
||||
}
|
||||
|
||||
function labelFromFileUrl(href: string): string {
|
||||
const decoded = decodeHrefPath(href);
|
||||
const trimmed = decoded.replace(TRAILING_SEPARATOR_REGEX, '');
|
||||
const slash = trimmed.lastIndexOf(PATH_SEPARATOR);
|
||||
return slash === -1 ? trimmed : trimmed.slice(slash + 1);
|
||||
}
|
||||
|
||||
// A trailing `/` in the target marks a directory and selects the folder
|
||||
// icon, matching the convention the mention picker inserts with.
|
||||
function iconElement(href: string): Element {
|
||||
return {
|
||||
type: 'element',
|
||||
tagName: 'svg',
|
||||
properties: {
|
||||
...MENTION_BADGE_SVG_ATTRIBUTES,
|
||||
className: MENTION_BADGE_ICON_CLASSNAME.split(' ').filter(Boolean)
|
||||
},
|
||||
children: getMentionBadgeIconPaths(href).map((d) => ({
|
||||
type: 'element',
|
||||
tagName: 'path',
|
||||
properties: { d },
|
||||
children: []
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
export const rehypeFileBadge: Plugin<[], Root> = () => {
|
||||
return (tree: Root) => {
|
||||
visit(tree, 'element', (node: Element) => {
|
||||
if (node.tagName !== 'a') return;
|
||||
|
||||
const props = node.properties ?? {};
|
||||
const href = typeof props.href === 'string' ? props.href : null;
|
||||
|
||||
if (!href || !href.startsWith(FILE_URI_PREFIX)) return;
|
||||
|
||||
const label = labelFromFileUrl(href);
|
||||
const titleAttr = typeof props.title === 'string' ? props.title : href;
|
||||
const decodedPath = decodeHrefPath(href);
|
||||
|
||||
node.tagName = 'span';
|
||||
node.properties = {
|
||||
className: MENTION_BADGE_CLASSNAME.split(' ').filter(Boolean),
|
||||
title: titleAttr.startsWith(FILE_URI_PREFIX) ? decodedPath : titleAttr
|
||||
};
|
||||
node.children = [
|
||||
iconElement(href),
|
||||
{
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: { className: ['shrink-0', 'truncate'] },
|
||||
children: [
|
||||
{
|
||||
type: 'text',
|
||||
value: getMentionBadgeLabel(
|
||||
label,
|
||||
decodedPath,
|
||||
settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
|
||||
toolsStore.serverHome
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
|
||||
import { URL_PARAMS } from '$lib/constants';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { AlertTriangle, ArrowRight } from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -22,7 +23,7 @@
|
||||
function handleSelectModel(model: string) {
|
||||
// Build URL with selected model, preserving other params
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.set('model', model);
|
||||
url.searchParams.set(URL_PARAMS.MODEL, model);
|
||||
|
||||
handleOpenChange(false);
|
||||
goto(url.toString());
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { highlightMatch } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
query: string;
|
||||
matchClass?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
text,
|
||||
query,
|
||||
matchClass = 'rounded bg-yellow-200/60 px-0.5 text-foreground dark:bg-yellow-500/30'
|
||||
}: Props = $props();
|
||||
|
||||
let segments = $derived(highlightMatch(text, query));
|
||||
</script>
|
||||
|
||||
{#each segments as seg, i (i)}
|
||||
{#if seg.match}
|
||||
<mark class={matchClass}>{seg.text}</mark>
|
||||
{:else}
|
||||
{seg.text}
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -42,3 +42,11 @@ export { default as KeyValuePairs } from './KeyValuePairs.svelte';
|
||||
* Supports placeholder, autofocus, and change callbacks.
|
||||
*/
|
||||
export { default as SearchInput } from './SearchInput.svelte';
|
||||
|
||||
/**
|
||||
* **HighlightedMatch** - Substring-match text highlight
|
||||
*
|
||||
* Renders `text` with each case-insensitive occurrence of `query` wrapped
|
||||
* in `<mark>`.
|
||||
*/
|
||||
export { default as HighlightedMatch } from './HighlightedMatch.svelte';
|
||||
|
||||
@@ -2,5 +2,4 @@ export const INITIAL_FILE_SIZE = 0;
|
||||
export const PROMPT_CONTENT_SEPARATOR = '\n\n';
|
||||
export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
|
||||
export const PROMPT_TRIGGER_PREFIX = '/';
|
||||
export const RESOURCE_TRIGGER_PREFIX = '@';
|
||||
export const NEW_CHAT_DRAFT_KEY = '__new_chat__';
|
||||
|
||||
@@ -39,6 +39,7 @@ export * from './max-bundle-size';
|
||||
export * from './mcp';
|
||||
export * from './mcp-form';
|
||||
export * from './mcp-resource';
|
||||
export * from './mention-badge';
|
||||
export * from './message-export';
|
||||
export * from './path-display';
|
||||
export * from './model-id';
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Visual contract for message @-mention badges. Svelte cannot be mounted
|
||||
* from a hast tree, so the rehype file-badge plugin emits the shared class
|
||||
* string below; keeping it here as a literal lets Tailwind's source
|
||||
* scanner generate the utility classes.
|
||||
*/
|
||||
export const MENTION_BADGE_CLASSNAME =
|
||||
'inline-flex w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md border border-border/50 bg-foreground/5 px-1.5 py-0.5 text-xs font-mono text-foreground hover:bg-foreground/10 dark:bg-foreground/10 dark:text-secondary-foreground';
|
||||
|
||||
export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';
|
||||
|
||||
/**
|
||||
* SVG attributes shared by the hast-built badge icons; the rehype plugin
|
||||
* spreads them onto the `<svg>` `properties`.
|
||||
*/
|
||||
export const MENTION_BADGE_SVG_ATTRIBUTES: Readonly<Record<string, string>> = {
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': '2',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
'aria-hidden': 'true'
|
||||
};
|
||||
|
||||
/**
|
||||
* SVG path strings for the badge's inline icon; each entry becomes one
|
||||
* `<path>` child of the wrapper `<svg>`. Paths match `lucide-svelte`'s
|
||||
* current `File` and `Folder` glyphs.
|
||||
*/
|
||||
export const MENTION_BADGE_FILE_ICON_PATHS: readonly string[] = [
|
||||
'M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z',
|
||||
'M14 2v5a1 1 0 0 0 1 1h5'
|
||||
];
|
||||
|
||||
export const MENTION_BADGE_FOLDER_ICON_PATHS: readonly string[] = [
|
||||
'M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'
|
||||
];
|
||||
@@ -1,4 +1,14 @@
|
||||
export const NEW_CHAT_PARAM = 'new_chat';
|
||||
/** Query params the chat routes read from the URL. */
|
||||
export const URL_PARAMS = {
|
||||
/** Prompt to send on arrival. */
|
||||
QUERY: 'q',
|
||||
/** Model to select. */
|
||||
MODEL: 'model',
|
||||
/** Load the selected model instead of waiting for the first message. */
|
||||
LOAD: 'load',
|
||||
/** Start a new chat. */
|
||||
NEW_CHAT: 'new_chat'
|
||||
} as const;
|
||||
|
||||
/** Settings section slugs — used for routes and navigation. */
|
||||
export const SETTINGS_SECTION_SLUGS = {
|
||||
@@ -16,7 +26,7 @@ export const ROUTES = {
|
||||
/** Root — start of the app. */
|
||||
START: '#/',
|
||||
/** New chat — root with new chat query param. */
|
||||
NEW_CHAT: `?${NEW_CHAT_PARAM}=true#/`,
|
||||
NEW_CHAT: `?${URL_PARAMS.NEW_CHAT}=true#/`,
|
||||
/** Chat base — for dynamic chat URLs use RouterService. */
|
||||
CHAT: '#/chat',
|
||||
/** MCP servers. */
|
||||
|
||||
@@ -31,8 +31,10 @@ export const SETTINGS_KEYS = {
|
||||
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
|
||||
SHOW_MODEL_TAGS: 'showModelTags',
|
||||
SHOW_BUILD_VERSION: 'showBuildVersion',
|
||||
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
|
||||
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
|
||||
RENDER_THINKING_AS_MARKDOWN: 'renderThinkingAsMarkdown',
|
||||
MENTION_SEARCH_MAX_DEPTH: 'mentionSearchMaxDepth',
|
||||
// Sampling
|
||||
TEMPERATURE: 'temperature',
|
||||
DYNATEMP_RANGE: 'dynatemp_range',
|
||||
|
||||
@@ -23,7 +23,12 @@ import type {
|
||||
SettingsSectionEntry,
|
||||
SettingsSection
|
||||
} from '$lib/types';
|
||||
import { CLI_FLAGS, DEFAULT_MCP_CONFIG } from '$lib/constants';
|
||||
import { CLI_FLAGS } from './cli-flags';
|
||||
import { DEFAULT_MCP_CONFIG } from './mcp';
|
||||
import {
|
||||
FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
|
||||
FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH
|
||||
} from './working-directory';
|
||||
import { SETTINGS_KEYS } from './settings-keys';
|
||||
import { ROUTES, SETTINGS_SECTION_SLUGS } from './routes';
|
||||
import { TITLE_GENERATION } from './title-generation';
|
||||
@@ -298,6 +303,14 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS,
|
||||
label: 'Show full path in mentions',
|
||||
help: 'Display the full file system path inside file and folder @-mention badges instead of just the file or folder name.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -555,6 +568,18 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
isPositiveInteger: true
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.MENTION_SEARCH_MAX_DEPTH,
|
||||
label: 'Mention search depth',
|
||||
help: 'How many directory levels below the working directory the @-mention file search descends. Larger values surface deeply nested files but take longer on large trees.',
|
||||
defaultValue: FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH,
|
||||
placeholder: `${FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH}`,
|
||||
min: 1,
|
||||
max: FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH,
|
||||
type: SettingsFieldType.INPUT,
|
||||
section: SETTINGS_SECTION_SLUGS.AGENTIC,
|
||||
isPositiveInteger: true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -38,3 +38,9 @@ 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;
|
||||
|
||||
/** Upper bound the mention search depth setting accepts. The server itself imposes no depth cap (0 = unlimited); this is a UI sanity bound. */
|
||||
export const FILE_GLOB_SEARCH_PICKERS_MAX_SEARCH_DEPTH = 32;
|
||||
|
||||
/** Depth the pickers fall back to when the user setting is invalid. */
|
||||
export const FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH = 10;
|
||||
|
||||
@@ -78,3 +78,8 @@ export enum PdfViewMode {
|
||||
TEXT = 'text',
|
||||
PAGES = 'pages'
|
||||
}
|
||||
|
||||
export enum FileMentionEntryType {
|
||||
FILE = 'file',
|
||||
DIRECTORY = 'directory'
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ export {
|
||||
MessageRole,
|
||||
MessageType,
|
||||
PdfViewMode,
|
||||
ReasoningFormat
|
||||
ReasoningFormat,
|
||||
FileMentionEntryType
|
||||
} from './chat.enums';
|
||||
|
||||
export { SessionRecordType } from './conversation-import.enums';
|
||||
|
||||
@@ -8,6 +8,7 @@ export enum KeyboardKey {
|
||||
ARROW_DOWN = 'ArrowDown',
|
||||
ARROW_LEFT = 'ArrowLeft',
|
||||
ARROW_RIGHT = 'ArrowRight',
|
||||
BACKSPACE = 'Backspace',
|
||||
TAB = 'Tab',
|
||||
B_LOWER = 'b',
|
||||
D_LOWER = 'd',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
|
||||
/**
|
||||
* Shared debounced async-search machinery for the chat-form pickers:
|
||||
* AbortController + sequence counter to discard stale responses, a
|
||||
* debounce, and a live `isSearching` flag.
|
||||
*/
|
||||
|
||||
export interface UseDebouncedSearchOptions {
|
||||
debounceMs: number;
|
||||
/** Fire-time guard: a scheduled call that outlives a reset is dropped. */
|
||||
canRun: () => boolean;
|
||||
/** Live query, used to drop a scheduled call whose query changed. */
|
||||
getQuery: () => string;
|
||||
/** Perform the search and commit results; bail out when `isCurrent()` is false. */
|
||||
run: (query: string, signal: AbortSignal, isCurrent: () => boolean) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function useDebouncedSearch(opts: UseDebouncedSearchOptions) {
|
||||
let controller: AbortController | null = null;
|
||||
let searchSeq = 0;
|
||||
let isSearching = $state(false);
|
||||
|
||||
function isCurrent(seq: number) {
|
||||
return seq === searchSeq;
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
controller?.abort();
|
||||
searchSeq++;
|
||||
isSearching = false;
|
||||
}
|
||||
|
||||
const schedule = debounce((query: string) => {
|
||||
if (!opts.canRun() || query !== opts.getQuery().trim()) return;
|
||||
void start(query);
|
||||
}, opts.debounceMs);
|
||||
|
||||
async function start(query: string) {
|
||||
cancel();
|
||||
const fresh = new AbortController();
|
||||
controller = fresh;
|
||||
const mySeq = ++searchSeq;
|
||||
isSearching = true;
|
||||
try {
|
||||
await opts.run(query, fresh.signal, () => isCurrent(mySeq));
|
||||
} finally {
|
||||
if (isCurrent(mySeq)) isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get isSearching() {
|
||||
return isSearching;
|
||||
},
|
||||
/** Bump the loading flag synchronously (e.g. before the debounce fires). */
|
||||
setLoading(value: boolean) {
|
||||
isSearching = value;
|
||||
},
|
||||
run(query: string) {
|
||||
schedule(query);
|
||||
},
|
||||
cancel
|
||||
};
|
||||
}
|
||||
|
||||
export type UseDebouncedSearchReturn = ReturnType<typeof useDebouncedSearch>;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { KeyboardKey } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* Shared keyboard navigation state for the chat-form pickers: a highlighted
|
||||
* row, a scroll trigger, and Arrow/Escape/Enter handling.
|
||||
*/
|
||||
export interface UsePickerNavigationOptions {
|
||||
/** Gates all key handling. */
|
||||
isOpen: () => boolean;
|
||||
count: () => number;
|
||||
/**
|
||||
* Resolve the row to highlight for a movement step, or -1 when no move
|
||||
* is possible. Defaults to plain wraparound across `count()`.
|
||||
*/
|
||||
step?: (from: number, dir: 1 | -1) => number;
|
||||
onClose: () => void;
|
||||
/** Called on Enter when `hoveredIndex` points at a selectable row. */
|
||||
onSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
function wrapStep(from: number, dir: 1 | -1, count: number): number {
|
||||
return dir === 1 ? (from + 1) % count : from <= 0 ? count - 1 : from - 1;
|
||||
}
|
||||
|
||||
export function usePickerNavigation(opts: UsePickerNavigationOptions) {
|
||||
let hoveredIndex = $state(-1);
|
||||
let scrollTrigger = $state(0);
|
||||
|
||||
function resolve(from: number, dir: 1 | -1): number {
|
||||
const n = opts.count();
|
||||
if (n === 0) return -1;
|
||||
if (opts.step) return opts.step(from, dir);
|
||||
return wrapStep(from, dir, n);
|
||||
}
|
||||
|
||||
function move(dir: 1 | -1) {
|
||||
const next = resolve(hoveredIndex, dir);
|
||||
if (next >= 0) {
|
||||
hoveredIndex = next;
|
||||
scrollTrigger++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the highlight without bumping the scroll trigger. */
|
||||
function reset(index: number) {
|
||||
hoveredIndex = index;
|
||||
}
|
||||
|
||||
/** Bump the scroll trigger without moving the highlight. */
|
||||
function bumpScroll() {
|
||||
scrollTrigger++;
|
||||
}
|
||||
|
||||
/** Mouse hover highlights a row but must NOT bump the scroll trigger. */
|
||||
function setHover(index: number) {
|
||||
hoveredIndex = index;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (!opts.isOpen()) return false;
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE) {
|
||||
event.preventDefault();
|
||||
opts.onClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
move(1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
move(-1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ENTER) {
|
||||
if (hoveredIndex >= 0 && hoveredIndex < opts.count()) {
|
||||
event.preventDefault();
|
||||
opts.onSelect(hoveredIndex);
|
||||
return true;
|
||||
}
|
||||
// No selectable row - let the caller's Enter-to-submit run.
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
get hoveredIndex() {
|
||||
return hoveredIndex;
|
||||
},
|
||||
get scrollTrigger() {
|
||||
return scrollTrigger;
|
||||
},
|
||||
reset,
|
||||
setHover,
|
||||
move,
|
||||
bumpScroll,
|
||||
handleKeydown
|
||||
};
|
||||
}
|
||||
|
||||
export type UsePickerNavigationReturn = ReturnType<typeof usePickerNavigation>;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
/**
|
||||
* Scrolls the highlighted row of a picker list into view when the scroll
|
||||
* trigger is bumped, without scrolling on mouse hover or result
|
||||
* replacement.
|
||||
*/
|
||||
export interface UseScrollActiveRowOptions {
|
||||
/** Counter bumped by keyboard nav; `undefined` disables the effect. */
|
||||
getTrigger: () => number | undefined;
|
||||
getContainer: () => HTMLDivElement | null;
|
||||
getIndex: () => number;
|
||||
getCount: () => number;
|
||||
/** Attribute prefix, e.g. 'picker' for `[data-picker-index="0"]`. */
|
||||
dataIndex: string;
|
||||
}
|
||||
|
||||
export function useScrollActiveRow(opts: UseScrollActiveRowOptions) {
|
||||
let lastTrigger: number | null = null;
|
||||
|
||||
$effect(() => {
|
||||
const trigger = opts.getTrigger();
|
||||
if (trigger === undefined) return;
|
||||
|
||||
// Skip the initial run on mount: the list opens with the first row
|
||||
// already in view, and scrolling here fires before the popover is
|
||||
// positioned, which would scroll the whole page to the top.
|
||||
if (lastTrigger === null) {
|
||||
lastTrigger = trigger;
|
||||
return;
|
||||
}
|
||||
|
||||
if (trigger === lastTrigger) return;
|
||||
lastTrigger = trigger;
|
||||
untrack(() => {
|
||||
const container = opts.getContainer();
|
||||
const index = opts.getIndex();
|
||||
if (!container || index < 0 || index >= opts.getCount()) return;
|
||||
const row = container.querySelector(
|
||||
`[data-${opts.dataIndex}-index="${index}"]`
|
||||
) as HTMLElement | null;
|
||||
row?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type UseScrollActiveRowReturn = ReturnType<typeof useScrollActiveRow>;
|
||||
Vendored
+11
-1
@@ -1,4 +1,4 @@
|
||||
import type { ErrorDialogType } from '$lib/enums';
|
||||
import type { ErrorDialogType, FileMentionEntryType } from '$lib/enums';
|
||||
import type { ApiChatCompletionToolCall } from './api';
|
||||
import type { DatabaseMessage, DatabaseMessageExtra } from './database';
|
||||
|
||||
@@ -166,3 +166,13 @@ export interface FileProcessingResult {
|
||||
extras: DatabaseMessageExtra[];
|
||||
emptyFiles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A file or folder picked in the @-mention picker. `path` is the absolute
|
||||
* server-side path; `name` is the basename.
|
||||
*/
|
||||
export interface FileMentionEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
type: FileMentionEntryType;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,8 @@ export type {
|
||||
LiveProcessingStats,
|
||||
LiveGenerationStats,
|
||||
AttachmentDisplayItemsOptions,
|
||||
FileProcessingResult
|
||||
FileProcessingResult,
|
||||
FileMentionEntry
|
||||
} from './chat.d';
|
||||
|
||||
// Database types
|
||||
|
||||
Vendored
+6
@@ -31,6 +31,9 @@ export interface SettingsEntry {
|
||||
radioOptions?: Array<{ value: string; label: string; key: string; isExperimental?: boolean }>;
|
||||
isExperimental?: boolean;
|
||||
isPositiveInteger?: boolean;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
dependsOn?: string;
|
||||
sync?: {
|
||||
serverKey: string;
|
||||
@@ -52,6 +55,9 @@ export interface SettingsFieldConfig {
|
||||
type: SettingsFieldType;
|
||||
isExperimental?: boolean;
|
||||
isPositiveInteger?: boolean;
|
||||
placeholder?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
dependsOn?: string;
|
||||
help?: string;
|
||||
options?: Array<{ value: string; label: string; icon?: typeof Icon }>;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Shared `file_glob_search` runners with a short-lived result cache, so a
|
||||
* repeated query for the same (type, path, glob, depth) reuses the last
|
||||
* result instead of re-walking the tree.
|
||||
*/
|
||||
|
||||
import { BuiltInTool, GlobSearchType } from '$lib/enums';
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import {
|
||||
GLOB_WILDCARD,
|
||||
PATH_NAV_MAX_DEPTH,
|
||||
PATH_SEPARATOR,
|
||||
WINDOWS_SEPARATOR
|
||||
} from '$lib/constants';
|
||||
import { lastPathSegment } from './path-display';
|
||||
import {
|
||||
buildGlobSearchArgs,
|
||||
joinPath,
|
||||
rankEntries,
|
||||
type GlobEntry,
|
||||
type GlobSearchArgs
|
||||
} from './working-directory';
|
||||
|
||||
const SEARCH_CACHE_TTL_MS = 2000;
|
||||
|
||||
interface CacheEntry {
|
||||
results: GlobEntry[];
|
||||
base: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
const searchCache = new Map<string, CacheEntry>();
|
||||
|
||||
export interface GlobSearchResult {
|
||||
base: string;
|
||||
entries: GlobEntry[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function runGlobSearch(
|
||||
args: GlobSearchArgs,
|
||||
type: GlobSearchType,
|
||||
limit: number,
|
||||
signal: AbortSignal
|
||||
): Promise<GlobSearchResult> {
|
||||
const key = `${type}\u0000${args.path}\u0000${args.include}\u0000${args.maxDepth}\u0000${limit}`;
|
||||
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: args.path, type, include: args.include, max_depth: args.maxDepth, 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[]) : [];
|
||||
const now = Date.now();
|
||||
// prune stale entries so the short-lived cache cannot grow unbounded
|
||||
for (const [k, v] of searchCache) {
|
||||
if (now - v.at >= SEARCH_CACHE_TTL_MS) searchCache.delete(k);
|
||||
}
|
||||
searchCache.set(key, { results: entries, base, at: now });
|
||||
return { base, entries };
|
||||
}
|
||||
|
||||
export interface GlobEntryResult {
|
||||
path: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface GlobSearchChildOptions {
|
||||
type?: GlobSearchType;
|
||||
/** Descend only on a trailing path separator (mention picker); off for
|
||||
* the WD picker, which descends on any exact match. */
|
||||
descendOnTrailingSeparator?: boolean;
|
||||
childMaxDepth?: number;
|
||||
}
|
||||
|
||||
export interface GlobSearchChildResult {
|
||||
base: string;
|
||||
args: GlobSearchArgs;
|
||||
/** Outer ranked entries plus the walked directory's children (absolute). */
|
||||
entries: GlobEntryResult[];
|
||||
/** Absolute path of the directory whose children were appended. */
|
||||
exactDir?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function toEntryResult(e: GlobEntry, base: string): GlobEntryResult {
|
||||
return { path: joinPath(base, e.path), name: lastPathSegment(e.path), type: e.type };
|
||||
}
|
||||
|
||||
/**
|
||||
* One ranked glob search that may also list the matched directory's
|
||||
* children, shared by the WD picker (descend on exact match) and the
|
||||
* mention picker (descend on a trailing `/` or `\`).
|
||||
*/
|
||||
export async function runGlobSearchWithChildren(
|
||||
query: string,
|
||||
scopePath: string,
|
||||
searchDepth: number,
|
||||
limit: number,
|
||||
signal: AbortSignal,
|
||||
options: GlobSearchChildOptions = {}
|
||||
): Promise<GlobSearchChildResult> {
|
||||
const {
|
||||
type = GlobSearchType.ALL,
|
||||
descendOnTrailingSeparator = false,
|
||||
childMaxDepth = PATH_NAV_MAX_DEPTH
|
||||
} = options;
|
||||
|
||||
const args = buildGlobSearchArgs(query, scopePath, searchDepth);
|
||||
const res = await runGlobSearch(args, type, limit, signal);
|
||||
if (res.error) return { base: res.base, args, entries: [], error: res.error };
|
||||
|
||||
const ranked = rankEntries(res.entries, args.rankQuery);
|
||||
const entries = ranked.map((e) => toEntryResult(e, res.base));
|
||||
|
||||
const last = args.last;
|
||||
if (last) {
|
||||
const wantsDescend = descendOnTrailingSeparator
|
||||
? query.endsWith(PATH_SEPARATOR) || query.endsWith(WINDOWS_SEPARATOR)
|
||||
: true;
|
||||
const exact = ranked.find(
|
||||
(e) => e.type === 'dir' && lastPathSegment(e.path).toLowerCase() === last.toLowerCase()
|
||||
);
|
||||
if (wantsDescend && exact) {
|
||||
const exactDir = joinPath(res.base, exact.path);
|
||||
const childRes = await runGlobSearch(
|
||||
{ path: exactDir, include: GLOB_WILDCARD, maxDepth: childMaxDepth, rankQuery: '' },
|
||||
type,
|
||||
limit,
|
||||
signal
|
||||
);
|
||||
if (!childRes.error) {
|
||||
const children = childRes.entries
|
||||
.map((e) => toEntryResult(e, childRes.base))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
return { base: res.base, args, entries: [...entries, ...children], exactDir };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { base: res.base, args, entries };
|
||||
}
|
||||
@@ -174,13 +174,47 @@ export {
|
||||
export {
|
||||
splitPathQuery,
|
||||
buildCaseInsensitiveGlob,
|
||||
buildGlobSearchArgs,
|
||||
rankEntries,
|
||||
joinPath,
|
||||
highlightMatch,
|
||||
type GlobEntry,
|
||||
type GlobSearchArgs,
|
||||
type PathQuery
|
||||
} from './working-directory';
|
||||
|
||||
// Shared `file_glob_search` runner with a short-lived result cache
|
||||
export {
|
||||
runGlobSearch,
|
||||
runGlobSearchWithChildren,
|
||||
type GlobEntryResult,
|
||||
type GlobSearchResult
|
||||
} from './glob-search';
|
||||
|
||||
// Mention-token detection (for the `@`-triggered file/folder mention picker)
|
||||
export {
|
||||
findMentionToken,
|
||||
takeMentionDismissSnapshot,
|
||||
type MentionDismissSnapshot
|
||||
} from './mention-token';
|
||||
|
||||
// Mention-chip visual contract shared by the rehype file-badge plugin,
|
||||
// plus the `[name](file://...)` link helpers the mention picker splices in
|
||||
export {
|
||||
fileMentionLinkRe,
|
||||
encodeFileLinkPath,
|
||||
decodeFileLinkPath,
|
||||
MENTION_BADGE_CLASSNAME,
|
||||
MENTION_BADGE_ICON_CLASSNAME,
|
||||
MENTION_BADGE_SVG_ATTRIBUTES,
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS,
|
||||
getMentionBadgeIconPaths,
|
||||
getMentionBadgeLabel,
|
||||
buildMentionInsertion,
|
||||
mentionLinkEndingAt
|
||||
} from './mention-badge';
|
||||
|
||||
// Agentic content utilities (structured section derivation)
|
||||
export {
|
||||
deriveAgenticSections,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { abbreviateHome, lastPathSegment } from './path-display';
|
||||
import {
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS
|
||||
} from '$lib/constants/mention-badge';
|
||||
import { FILE_URI_PREFIX } from '$lib/constants';
|
||||
import { FileMentionEntryType } from '$lib/enums';
|
||||
import type { FileMentionEntry } from '$lib/types';
|
||||
|
||||
export {
|
||||
MENTION_BADGE_CLASSNAME,
|
||||
MENTION_BADGE_ICON_CLASSNAME,
|
||||
MENTION_BADGE_SVG_ATTRIBUTES,
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS
|
||||
} from '$lib/constants/mention-badge';
|
||||
|
||||
// `)` is allowed in a path only when not followed by whitespace or `[`,
|
||||
// so macOS paths parse while adjacent badges still terminate the match.
|
||||
const FILE_MENTION_LINK_SOURCE = String.raw`\[([^\]\n]+?)\]\(file:\/\/((?:[^)\n]|\)(?![\s[]))+)\)`;
|
||||
|
||||
export function fileMentionLinkRe(flags = ''): RegExp {
|
||||
return new RegExp(FILE_MENTION_LINK_SOURCE, flags);
|
||||
}
|
||||
|
||||
// Escape each path segment for a markdown link destination (spaces/parens
|
||||
// break CommonMark); keeps the trailing slash that marks a directory.
|
||||
export function encodeFileLinkPath(path: string): string {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
// Malformed escape sequences fall back to the input unchanged.
|
||||
export function decodeFileLinkPath(path: string): string {
|
||||
try {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => decodeURIComponent(segment))
|
||||
.join('/');
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMentionBadgeIconPaths(path: string): readonly string[] {
|
||||
return path.endsWith('/') ? MENTION_BADGE_FOLDER_ICON_PATHS : MENTION_BADGE_FILE_ICON_PATHS;
|
||||
}
|
||||
|
||||
export function getMentionBadgeLabel(
|
||||
name: string,
|
||||
path: string,
|
||||
showFullPath: boolean,
|
||||
home?: string | null
|
||||
): string {
|
||||
if (!showFullPath) return name;
|
||||
const decoded = decodeFileLinkPath(path.replace(/\/+$/, ''));
|
||||
if (!decoded) return name;
|
||||
return abbreviateHome(decoded, home);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extent of the mention link ending exactly at `caret`, so Backspace there
|
||||
* deletes the whole `[name](file://...)` token in one keystroke instead of
|
||||
* unraveling it character by character. Null when no link ends at `caret`.
|
||||
*/
|
||||
export function mentionLinkEndingAt(
|
||||
value: string,
|
||||
caret: number
|
||||
): { start: number; end: number } | null {
|
||||
const re = fileMentionLinkRe('g');
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(value)) !== null) {
|
||||
const end = match.index + match[0].length;
|
||||
if (end === caret) return { start: match.index, end };
|
||||
if (end > caret) break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the markdown link that replaces a mention token. Entry `path` is
|
||||
* already rooted, so `file://` + `/abs` yields the canonical `file:///`.
|
||||
* Null when the token is invalid.
|
||||
*/
|
||||
export function buildMentionInsertion(
|
||||
entry: FileMentionEntry,
|
||||
value: string,
|
||||
token: { start: number; end: number }
|
||||
): { newValue: string; caretOffset: number } | null {
|
||||
if (token.start < 0 || token.end > value.length || token.start > token.end) return null;
|
||||
// Strip the entry's directory marker so it is not doubled below.
|
||||
const cleanedPath = entry.path.replace(/\/+$/, '');
|
||||
const pathWithSeparator =
|
||||
entry.type === FileMentionEntryType.DIRECTORY ? `${cleanedPath}/` : cleanedPath;
|
||||
const basename = lastPathSegment(cleanedPath) || entry.name;
|
||||
const insertion = `[${basename}](${FILE_URI_PREFIX}${encodeFileLinkPath(pathWithSeparator)}) `;
|
||||
const newValue = value.slice(0, token.start) + insertion + value.slice(token.end);
|
||||
return { newValue, caretOffset: token.start + insertion.length };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// An `@` starts a mention only when preceded by start-of-string or one of
|
||||
// these; identifier chars are not delimiters, so a mid-word `@` does not.
|
||||
const TOKEN_BOUNDARY_CHARS = new Set([
|
||||
' ',
|
||||
'\t',
|
||||
'\n',
|
||||
'\r',
|
||||
'(',
|
||||
')',
|
||||
'[',
|
||||
']',
|
||||
',',
|
||||
';',
|
||||
':',
|
||||
'"',
|
||||
"'"
|
||||
]);
|
||||
|
||||
/**
|
||||
* Find the most-recent `@`-mention token whose extent includes `cursor`;
|
||||
* the query covers the whole `@...` token regardless of caret position.
|
||||
*/
|
||||
export function findMentionToken(
|
||||
value: string,
|
||||
cursor: number
|
||||
): { start: number; end: number; query: string } | null {
|
||||
if (cursor <= 0 || cursor > value.length) return null;
|
||||
|
||||
let atIndex = -1;
|
||||
for (let i = cursor - 1; i >= 0; i--) {
|
||||
const ch = value[i];
|
||||
if (ch === '@') {
|
||||
const prev = i > 0 ? value[i - 1] : '';
|
||||
if (i === 0 || TOKEN_BOUNDARY_CHARS.has(prev)) {
|
||||
atIndex = i;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (TOKEN_BOUNDARY_CHARS.has(ch)) break;
|
||||
}
|
||||
|
||||
if (atIndex === -1) return null;
|
||||
|
||||
let end = atIndex + 1;
|
||||
while (end < value.length && !TOKEN_BOUNDARY_CHARS.has(value[end])) {
|
||||
end++;
|
||||
}
|
||||
|
||||
return {
|
||||
start: atIndex,
|
||||
end,
|
||||
query: value.slice(atIndex + 1, end)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature of a mention token for use as a "dismissed" marker:
|
||||
* while the picker is closed and this exact token is still intact, the
|
||||
* picker does not silently re-open on in-token edits.
|
||||
*/
|
||||
export interface MentionDismissSnapshot {
|
||||
start: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export function takeMentionDismissSnapshot(
|
||||
value: string,
|
||||
cursor: number
|
||||
): MentionDismissSnapshot | null {
|
||||
const token = findMentionToken(value, cursor);
|
||||
if (!token) return null;
|
||||
return { start: token.start, query: token.query };
|
||||
}
|
||||
@@ -9,22 +9,14 @@ import {
|
||||
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.
|
||||
*/
|
||||
// `~/...` under `home`; falls back to the basename when home is unknown
|
||||
// or the path is outside it.
|
||||
export function abbreviateWorkingDir(
|
||||
path: string | null | undefined,
|
||||
home: string | null | undefined
|
||||
@@ -37,12 +29,8 @@ export function abbreviateWorkingDir(
|
||||
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.
|
||||
*/
|
||||
// Unlike abbreviateWorkingDir, paths outside `home` are returned
|
||||
// unchanged - used 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;
|
||||
@@ -61,10 +49,9 @@ export interface CwdMessageInfo {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Format a synthetic cwd-change message. The path travels as
|
||||
* `[file:///abs/path](display)` so both the absolute and 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);
|
||||
@@ -72,10 +59,9 @@ export function formatCwdMessage(cwd: string, home: string | null): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function parseCwdMessage(content: string): CwdMessageInfo | null {
|
||||
const trimmed = content.trim();
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
* Pure helpers for the working-directory picker search, backed by the
|
||||
* server's `file_glob_search` tool. Queries starting from a root (`/`,
|
||||
* `C:\`, `\\host\share`) or `~` navigate the tree (search the parent for
|
||||
* the last segment); anything else glob-matches home-relative entries.
|
||||
*/
|
||||
|
||||
import { PATH_SEPARATOR } from '$lib/constants/mcp-resource';
|
||||
@@ -21,6 +16,7 @@ import {
|
||||
GLOB_WILDCARD,
|
||||
HOME_TILDE,
|
||||
LEADING_SLASHES_REGEX,
|
||||
PATH_NAV_MAX_DEPTH,
|
||||
UNC_ROOT_REGEX,
|
||||
WINDOWS_SEPARATOR
|
||||
} from '$lib/constants';
|
||||
@@ -45,10 +41,6 @@ function toPosixSeparators(query: string): string {
|
||||
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;
|
||||
@@ -84,7 +76,6 @@ export function splitPathQuery(query: string): PathQuery | null {
|
||||
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) {
|
||||
@@ -99,7 +90,33 @@ export function buildCaseInsensitiveGlob(query: string): string {
|
||||
return out + GLOB_WILDCARD;
|
||||
}
|
||||
|
||||
/** Exact basename first, then prefix, then substring; lower is better. */
|
||||
export interface GlobSearchArgs {
|
||||
path: string;
|
||||
include: string;
|
||||
maxDepth: number;
|
||||
rankQuery: string;
|
||||
/** Last segment of a path-navigation query (`~/dir/sub`), undefined for
|
||||
* a plain home-relative glob. Lets callers act on the exact targeted
|
||||
* segment (e.g. the WD picker "entering" a directory). */
|
||||
last?: string;
|
||||
}
|
||||
|
||||
export function buildGlobSearchArgs(
|
||||
query: string,
|
||||
scopePath: string,
|
||||
searchDepth: number
|
||||
): GlobSearchArgs {
|
||||
const pathQuery = splitPathQuery(query);
|
||||
const path = pathQuery ? pathQuery.parent : scopePath;
|
||||
const include = pathQuery
|
||||
? pathQuery.last
|
||||
? buildCaseInsensitiveGlob(pathQuery.last)
|
||||
: GLOB_WILDCARD
|
||||
: buildCaseInsensitiveGlob(query);
|
||||
const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : searchDepth;
|
||||
return { path, include, maxDepth, rankQuery: pathQuery?.last ?? query, last: pathQuery?.last };
|
||||
}
|
||||
|
||||
const RANK_EXACT = 0;
|
||||
const RANK_PREFIX = 1;
|
||||
const RANK_SUBSTRING = 2;
|
||||
@@ -114,7 +131,6 @@ function rankScore(path: string, query: string): number {
|
||||
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) =>
|
||||
@@ -124,13 +140,11 @@ export function rankEntries(entries: GlobEntry[], query: string): GlobEntry[] {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 }[] = [];
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore, isConversationsInitialized } from '$lib/stores/conversations.svelte';
|
||||
import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { APP_NAME, NEW_CHAT_PARAM } from '$lib/constants';
|
||||
import { APP_NAME, URL_PARAMS } from '$lib/constants';
|
||||
|
||||
let qParam = $derived(page.url.searchParams.get('q'));
|
||||
let modelParam = $derived(page.url.searchParams.get('model'));
|
||||
let newChatParam = $derived(page.url.searchParams.get(NEW_CHAT_PARAM));
|
||||
let qParam = $derived(page.url.searchParams.get(URL_PARAMS.QUERY));
|
||||
let modelParam = $derived(page.url.searchParams.get(URL_PARAMS.MODEL));
|
||||
let newChatParam = $derived(page.url.searchParams.get(URL_PARAMS.NEW_CHAT));
|
||||
let loadParam = $derived(page.url.searchParams.get(URL_PARAMS.LOAD));
|
||||
|
||||
// Dialog state for model not available error
|
||||
let showModelNotAvailable = $state(false);
|
||||
@@ -23,9 +25,10 @@
|
||||
function clearUrlParams() {
|
||||
const url = new URL(page.url);
|
||||
|
||||
url.searchParams.delete('q');
|
||||
url.searchParams.delete('model');
|
||||
url.searchParams.delete(NEW_CHAT_PARAM);
|
||||
url.searchParams.delete(URL_PARAMS.QUERY);
|
||||
url.searchParams.delete(URL_PARAMS.MODEL);
|
||||
url.searchParams.delete(URL_PARAMS.LOAD);
|
||||
url.searchParams.delete(URL_PARAMS.NEW_CHAT);
|
||||
|
||||
replaceState(url.toString(), {});
|
||||
}
|
||||
@@ -39,6 +42,14 @@
|
||||
if (model) {
|
||||
try {
|
||||
await modelsStore.selectModelById(model.id);
|
||||
|
||||
// with ?load=true, start loading right away so the model is ready sooner;
|
||||
// not awaited, so the UI stays usable during the load
|
||||
if (loadParam === 'true' && isRouterMode() && !modelsStore.isModelLoaded(model.id)) {
|
||||
modelsStore
|
||||
.loadModel(model.id)
|
||||
.catch((error) => console.error('Failed to load model:', error));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select model:', error);
|
||||
requestedModelName = modelParam;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import { DialogModelNotAvailable } from '$lib/components/app';
|
||||
import { APP_NAME, ROUTES } from '$lib/constants';
|
||||
import { APP_NAME, ROUTES, URL_PARAMS } from '$lib/constants';
|
||||
import { chatStore } from '$lib/stores/chat.svelte';
|
||||
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
|
||||
import { modelsStore, modelOptions } from '$lib/stores/models.svelte';
|
||||
@@ -12,8 +12,8 @@
|
||||
let currentChatId: string | undefined = undefined;
|
||||
|
||||
// URL parameters for prompt and model selection
|
||||
let qParam = $derived(page.url.searchParams.get('q'));
|
||||
let modelParam = $derived(page.url.searchParams.get('model'));
|
||||
let qParam = $derived(page.url.searchParams.get(URL_PARAMS.QUERY));
|
||||
let modelParam = $derived(page.url.searchParams.get(URL_PARAMS.MODEL));
|
||||
|
||||
// Dialog state for model not available error
|
||||
let showModelNotAvailable = $state(false);
|
||||
@@ -28,8 +28,8 @@
|
||||
*/
|
||||
function clearUrlParams() {
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.delete('q');
|
||||
url.searchParams.delete('model');
|
||||
url.searchParams.delete(URL_PARAMS.QUERY);
|
||||
url.searchParams.delete(URL_PARAMS.MODEL);
|
||||
replaceState(url.toString(), {});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const items: Item[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: String(i),
|
||||
label: `item ${i}`
|
||||
}));
|
||||
|
||||
let open = $state(false);
|
||||
let scrollTrigger = $state(0);
|
||||
let selectedIndex = $state(0);
|
||||
|
||||
export function openPicker() {
|
||||
open = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="height: 5000px;">conversation</div>
|
||||
|
||||
{#if open}
|
||||
<div data-testid="picker-host">
|
||||
<ChatFormPickerList
|
||||
{items}
|
||||
isLoading={false}
|
||||
{selectedIndex}
|
||||
searchQuery=""
|
||||
showSearchInput={false}
|
||||
{scrollTrigger}
|
||||
itemKey={(it) => it.id}
|
||||
>
|
||||
{#snippet item(it, index, isSelected)}
|
||||
<ChatFormPickerListItem dataIndex={index} {isSelected} onclick={() => {}}>
|
||||
{it.label}
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Regression test: opening a chat-form picker must not scroll the
|
||||
// conversation to the top. Root cause: the list's scroll effect fired
|
||||
// scrollIntoView on the initial mount, before the popover was positioned,
|
||||
// so the browser scrolled every scrollable ancestor to reveal the row.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import PickerListScrollHarness from './components/PickerListScrollHarness.svelte';
|
||||
|
||||
describe('ChatFormPickerList mount scroll', () => {
|
||||
it('does not scroll documentElement when the picker mounts', async () => {
|
||||
const screen = render(PickerListScrollHarness);
|
||||
await tick();
|
||||
|
||||
document.documentElement.scrollTop = document.documentElement.scrollHeight;
|
||||
await tick();
|
||||
const before = document.documentElement.scrollTop;
|
||||
expect(before).toBeGreaterThan(0);
|
||||
|
||||
screen.component.openPicker();
|
||||
await tick();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await tick();
|
||||
|
||||
const after = document.documentElement.scrollTop;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/services/tools.service', () => ({
|
||||
ToolsService: { executeToolRaw: vi.fn() }
|
||||
}));
|
||||
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import { GlobSearchType } from '$lib/enums';
|
||||
import { runGlobSearchWithChildren } from '$lib/utils';
|
||||
|
||||
const mockExecute = vi.mocked(ToolsService.executeToolRaw);
|
||||
|
||||
// Distinct roots per test so the module-level search cache never serves a
|
||||
// prior test's result under the same (type, path, glob, depth) key.
|
||||
beforeEach(() => {
|
||||
mockExecute.mockReset();
|
||||
});
|
||||
|
||||
describe('runGlobSearchWithChildren', () => {
|
||||
it('returns ranked outer entries as absolute paths without descending', async () => {
|
||||
mockExecute.mockResolvedValueOnce({
|
||||
base: '/Users/rootA',
|
||||
entries: [
|
||||
{ path: 'note.md', type: 'file' },
|
||||
{ path: 'src', type: 'dir' }
|
||||
]
|
||||
});
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'note',
|
||||
'/Users/rootA',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal
|
||||
);
|
||||
expect(res.error).toBeUndefined();
|
||||
expect(res.entries.map((e) => e.path)).toEqual(['/Users/rootA/note.md', '/Users/rootA/src']);
|
||||
expect(res.exactDir).toBeUndefined();
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('appends a matched directorys children when the query ends with a separator', async () => {
|
||||
mockExecute
|
||||
.mockResolvedValueOnce({ base: '/Users/rootB', entries: [{ path: 'src', type: 'dir' }] })
|
||||
.mockResolvedValueOnce({
|
||||
base: '/Users/rootB/src',
|
||||
entries: [
|
||||
{ path: 'a.txt', type: 'file' },
|
||||
{ path: 'sub', type: 'dir' }
|
||||
]
|
||||
});
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'/Users/rootB/src/',
|
||||
'/Users/rootB',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal,
|
||||
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
|
||||
);
|
||||
expect(res.error).toBeUndefined();
|
||||
expect(res.exactDir).toBe('/Users/rootB/src');
|
||||
expect(res.entries.map((e) => e.path)).toEqual([
|
||||
'/Users/rootB/src',
|
||||
'/Users/rootB/src/a.txt',
|
||||
'/Users/rootB/src/sub'
|
||||
]);
|
||||
expect(mockExecute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not descend without a trailing separator in mention mode', async () => {
|
||||
mockExecute.mockResolvedValueOnce({
|
||||
base: '/Users/rootC',
|
||||
entries: [{ path: 'src', type: 'dir' }]
|
||||
});
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'/Users/rootC/src',
|
||||
'/Users/rootC',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal,
|
||||
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
|
||||
);
|
||||
expect(res.exactDir).toBeUndefined();
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('descends on an exact directory match in WD mode', async () => {
|
||||
mockExecute
|
||||
.mockResolvedValueOnce({ base: '/Users/rootD', entries: [{ path: 'src', type: 'dir' }] })
|
||||
.mockResolvedValueOnce({
|
||||
base: '/Users/rootD/src',
|
||||
entries: [{ path: 'a.txt', type: 'file' }]
|
||||
});
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'/Users/rootD/src',
|
||||
'/Users/rootD',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal,
|
||||
{ type: GlobSearchType.DIR }
|
||||
);
|
||||
expect(res.exactDir).toBe('/Users/rootD/src');
|
||||
expect(res.entries.map((e) => e.path)).toEqual(['/Users/rootD/src', '/Users/rootD/src/a.txt']);
|
||||
expect(mockExecute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('surfaces a server error without attempting a child walk', async () => {
|
||||
mockExecute.mockResolvedValueOnce({ error: 'boom' });
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'src',
|
||||
'/Users/rootE',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal
|
||||
);
|
||||
expect(res.error).toBe('boom');
|
||||
expect(res.entries).toEqual([]);
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS,
|
||||
buildMentionInsertion,
|
||||
decodeFileLinkPath,
|
||||
encodeFileLinkPath,
|
||||
fileMentionLinkRe,
|
||||
getMentionBadgeIconPaths,
|
||||
getMentionBadgeLabel,
|
||||
mentionLinkEndingAt
|
||||
} from '$lib/utils';
|
||||
import { FileMentionEntryType } from '$lib/enums';
|
||||
|
||||
describe('encodeFileLinkPath', () => {
|
||||
it('leaves a clean path unchanged', () => {
|
||||
expect(encodeFileLinkPath('/Users/foo/bar.txt')).toBe('/Users/foo/bar.txt');
|
||||
});
|
||||
|
||||
it('encodes spaces per path segment', () => {
|
||||
expect(
|
||||
encodeFileLinkPath('/Users/allozaur/Desktop/Screenshot 2026-08-05 at 11.33.45.png')
|
||||
).toBe('/Users/allozaur/Desktop/Screenshot%202026-08-05%20at%2011.33.45.png');
|
||||
});
|
||||
|
||||
it('preserves the leading and trailing slash (directory marker)', () => {
|
||||
expect(encodeFileLinkPath('/Users/foo/bar/')).toBe('/Users/foo/bar/');
|
||||
});
|
||||
|
||||
it('encodes parentheses in macOS screenshot names', () => {
|
||||
expect(encodeFileLinkPath('/Users/foo/Pic (1).png')).toBe('/Users/foo/Pic%20(1).png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileMentionLinkRe', () => {
|
||||
it('matches a standard mention link', () => {
|
||||
expect(fileMentionLinkRe().test('[docs](file:///a/b)')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match non-file links', () => {
|
||||
expect(fileMentionLinkRe().test('[foo](https://example.com)')).toBe(false);
|
||||
expect(fileMentionLinkRe().test('plain text')).toBe(false);
|
||||
});
|
||||
|
||||
it('admits a close paren in a macOS-style file name', () => {
|
||||
const match = fileMentionLinkRe().exec(
|
||||
'[Screenshot (1).png](file:///Users/foo/Screenshot (1).png)'
|
||||
);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.[1]).toBe('Screenshot (1).png');
|
||||
expect(match?.[2]).toBe('/Users/foo/Screenshot (1).png');
|
||||
});
|
||||
|
||||
it('admits a parenthesized folder segment', () => {
|
||||
expect(
|
||||
fileMentionLinkRe().exec('[main.rs](file:///Users/foo/Project (Stuff)/main.rs)')?.[2]
|
||||
).toBe('/Users/foo/Project (Stuff)/main.rs');
|
||||
});
|
||||
|
||||
it('stops at the closing paren of an adjacent badge', () => {
|
||||
expect(fileMentionLinkRe().exec('[a](file:///p)[b](file:///q)')?.[0]).toBe('[a](file:///p)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMentionBadgeIconPaths', () => {
|
||||
it('returns the folder glyphs for a trailing-separator path', () => {
|
||||
expect(getMentionBadgeIconPaths('/Users/foo/bar/')).toBe(MENTION_BADGE_FOLDER_ICON_PATHS);
|
||||
});
|
||||
|
||||
it('returns the file glyphs otherwise', () => {
|
||||
expect(getMentionBadgeIconPaths('/Users/foo/bar.txt')).toBe(MENTION_BADGE_FILE_ICON_PATHS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMentionBadgeLabel', () => {
|
||||
it('returns the name by default', () => {
|
||||
expect(getMentionBadgeLabel('bar', '/Users/foo/bar/', false)).toBe('bar');
|
||||
});
|
||||
|
||||
it('renders the decoded full path without the trailing separator', () => {
|
||||
expect(getMentionBadgeLabel('bar', '/Users/foo/bar/', true)).toBe('/Users/foo/bar');
|
||||
expect(getMentionBadgeLabel('shot', '/Users/foo/Screenshot%20(1).png', true)).toBe(
|
||||
'/Users/foo/Screenshot (1).png'
|
||||
);
|
||||
});
|
||||
|
||||
it('abbreviates a known home prefix to a tilde', () => {
|
||||
expect(getMentionBadgeLabel('main.rs', '/home/user/src/main.rs', true, '/home/user')).toBe(
|
||||
'~/src/main.rs'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the name when the decoded path is empty', () => {
|
||||
expect(getMentionBadgeLabel('root', '/', true)).toBe('root');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeFileLinkPath', () => {
|
||||
it('decodes encoded segments back to the original path', () => {
|
||||
expect(
|
||||
decodeFileLinkPath('/Users/allozaur/Desktop/Screenshot%202026-08-05%20at%2011.33.45.png')
|
||||
).toBe('/Users/allozaur/Desktop/Screenshot 2026-08-05 at 11.33.45.png');
|
||||
});
|
||||
|
||||
it('is the inverse of encodeFileLinkPath', () => {
|
||||
for (const path of [
|
||||
'/a/b.txt',
|
||||
'/Users/foo/Desktop/Screenshot 2026-08-05 at 11.33.45.png',
|
||||
'/Users/foo/bar (1)/dir/',
|
||||
'/sp ace/pa%th.txt'
|
||||
]) {
|
||||
expect(decodeFileLinkPath(encodeFileLinkPath(path))).toBe(path);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the input on malformed percent sequences', () => {
|
||||
expect(decodeFileLinkPath('/a/%zz.txt')).toBe('/a/%zz.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMentionInsertion', () => {
|
||||
const file = (path: string, name: string) => ({
|
||||
path,
|
||||
name,
|
||||
type: FileMentionEntryType.FILE
|
||||
});
|
||||
const dir = (path: string, name: string) => ({
|
||||
path,
|
||||
name,
|
||||
type: FileMentionEntryType.DIRECTORY
|
||||
});
|
||||
|
||||
it('splices a root-anchored file link in place of the token', () => {
|
||||
const value = 'hello @repo';
|
||||
const result = buildMentionInsertion(file('/Users/foo/myRepo', 'myRepo'), value, {
|
||||
start: 6,
|
||||
end: 11
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
const { newValue, caretOffset } = result!;
|
||||
expect(newValue).toBe('hello [myRepo](file:///Users/foo/myRepo) ');
|
||||
expect(caretOffset).toBe(6 + '[myRepo](file:///Users/foo/myRepo) '.length);
|
||||
});
|
||||
|
||||
it('keeps the trailing slash on the directory marker', () => {
|
||||
const value = 'see @src';
|
||||
const { newValue } = buildMentionInsertion(dir('/Users/foo/myRepo/src/', 'src'), value, {
|
||||
start: 4,
|
||||
end: 8
|
||||
})!;
|
||||
expect(newValue).toBe('see [src](file:///Users/foo/myRepo/src/) ');
|
||||
});
|
||||
|
||||
it('escapes spaces and parens in the target', () => {
|
||||
const value = '@pic';
|
||||
const { newValue } = buildMentionInsertion(
|
||||
file('/Users/foo/Desktop/Pic (1).png', 'Pic (1).png'),
|
||||
value,
|
||||
{ start: 0, end: 4 }
|
||||
)!;
|
||||
expect(newValue).toBe('[Pic (1).png](file:///Users/foo/Desktop/Pic%20(1).png) ');
|
||||
});
|
||||
|
||||
it('re-adds the directory marker when the cleaned path empties', () => {
|
||||
const { newValue } = buildMentionInsertion(dir('/', 'root'), '/', { start: 0, end: 1 })!;
|
||||
expect(newValue).toBe('[root](file:///) ');
|
||||
});
|
||||
|
||||
it('returns null for an out-of-range token', () => {
|
||||
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 0, end: 5 })).toBeNull();
|
||||
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 2, end: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mentionLinkEndingAt', () => {
|
||||
const LINK = '[docs](file:///a/b)';
|
||||
|
||||
it('returns the extent when the caret is exactly at the link end', () => {
|
||||
expect(mentionLinkEndingAt(`see ${LINK} here`, 4 + LINK.length)).toEqual({
|
||||
start: 4,
|
||||
end: 4 + LINK.length
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the caret is inside or past the link', () => {
|
||||
expect(mentionLinkEndingAt(LINK, LINK.length - 1)).toBeNull();
|
||||
expect(mentionLinkEndingAt(`${LINK} `, LINK.length + 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-file links and plain text', () => {
|
||||
expect(mentionLinkEndingAt('[foo](https://example.com)', 26)).toBeNull();
|
||||
expect(mentionLinkEndingAt('plain', 5)).toBeNull();
|
||||
});
|
||||
|
||||
it('picks the link that ends at the caret when several exist', () => {
|
||||
const value = `${LINK} and ${LINK}`;
|
||||
expect(mentionLinkEndingAt(value, value.length)).toEqual({
|
||||
start: value.length - LINK.length,
|
||||
end: value.length
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findMentionToken, takeMentionDismissSnapshot } from '$lib/utils';
|
||||
|
||||
describe('findMentionToken', () => {
|
||||
it('returns null for an empty/bare cursor', () => {
|
||||
expect(findMentionToken('', 0)).toBeNull();
|
||||
expect(findMentionToken('text', 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('recognizes a mention at the start of the value', () => {
|
||||
expect(findMentionToken('@pr', 3)).toEqual({ start: 0, end: 3, query: 'pr' });
|
||||
});
|
||||
|
||||
it('recognizes a mention after a word boundary', () => {
|
||||
expect(findMentionToken('hello @pr', 9)).toEqual({ start: 6, end: 9, query: 'pr' });
|
||||
});
|
||||
|
||||
it('returns null when the @ is mid-identifier', () => {
|
||||
expect(findMentionToken('em@', 3)).toBeNull();
|
||||
expect(findMentionToken('text@pr', 7)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the cursor is past the whitespace break', () => {
|
||||
expect(findMentionToken('@pr hello', 9)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats boundary characters (parens, brackets, comma) as token starts', () => {
|
||||
expect(findMentionToken('(@pr', 4)).toEqual({ start: 1, end: 4, query: 'pr' });
|
||||
expect(findMentionToken('[@pr', 4)).toEqual({ start: 1, end: 4, query: 'pr' });
|
||||
expect(findMentionToken('a,@pr', 5)).toEqual({ start: 2, end: 5, query: 'pr' });
|
||||
});
|
||||
|
||||
it('does not treat an identifier character as a boundary', () => {
|
||||
expect(findMentionToken('user@abc', 8)).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts the whole token up to the trailing boundary as the query', () => {
|
||||
expect(findMentionToken('@', 1)).toEqual({ start: 0, end: 1, query: '' });
|
||||
expect(findMentionToken('@hello', 6)).toEqual({ start: 0, end: 6, query: 'hello' });
|
||||
});
|
||||
|
||||
it('keeps the whole token as the query when the caret is mid-token', () => {
|
||||
expect(findMentionToken('@hello', 4)).toEqual({ start: 0, end: 6, query: 'hello' });
|
||||
expect(findMentionToken('@hello world', 4)).toEqual({ start: 0, end: 6, query: 'hello' });
|
||||
});
|
||||
|
||||
it('ignores a boundary @ and keeps the most recent token', () => {
|
||||
expect(findMentionToken('a @foo @bar', 11)).toEqual({ start: 7, end: 11, query: 'bar' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('takeMentionDismissSnapshot', () => {
|
||||
it('returns null when there is no valid mention at the cursor', () => {
|
||||
expect(takeMentionDismissSnapshot('plain text', 5)).toBeNull();
|
||||
expect(takeMentionDismissSnapshot('user@abc', 8)).toBeNull();
|
||||
});
|
||||
|
||||
it('captures start and query of the current mention', () => {
|
||||
expect(takeMentionDismissSnapshot('hello @proj', 11)).toEqual({
|
||||
start: 6,
|
||||
query: 'proj'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
splitPathQuery,
|
||||
buildCaseInsensitiveGlob,
|
||||
buildGlobSearchArgs,
|
||||
rankEntries,
|
||||
joinPath,
|
||||
highlightMatch
|
||||
} from '$lib/utils';
|
||||
import { GLOB_WILDCARD, PATH_NAV_MAX_DEPTH } from '$lib/constants';
|
||||
|
||||
describe('splitPathQuery', () => {
|
||||
it('treats a plain query as a home-relative glob (not navigation)', () => {
|
||||
@@ -124,3 +126,41 @@ describe('highlightMatch', () => {
|
||||
expect(highlightMatch('abc', 'z')).toEqual([{ text: 'abc', match: false }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGlobSearchArgs', () => {
|
||||
const DEPTH = 6;
|
||||
|
||||
it('glob-matches home-relative within the scope path', () => {
|
||||
const args = buildGlobSearchArgs('docs', '/home', DEPTH);
|
||||
expect(args.path).toBe('/home');
|
||||
expect(args.include).toBe(buildCaseInsensitiveGlob('docs'));
|
||||
expect(args.maxDepth).toBe(DEPTH);
|
||||
expect(args.rankQuery).toBe('docs');
|
||||
expect(args.last).toBeUndefined();
|
||||
});
|
||||
|
||||
it('navigates home for a `~` path query', () => {
|
||||
const args = buildGlobSearchArgs('~/proj', '/home', DEPTH);
|
||||
expect(args.path).toBe('~');
|
||||
expect(args.include).toBe(buildCaseInsensitiveGlob('proj'));
|
||||
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
|
||||
expect(args.rankQuery).toBe('proj');
|
||||
expect(args.last).toBe('proj');
|
||||
});
|
||||
|
||||
it('lists the scope root when a path query has no last segment', () => {
|
||||
const args = buildGlobSearchArgs('~/', '/home', DEPTH);
|
||||
expect(args.path).toBe('~');
|
||||
expect(args.include).toBe(GLOB_WILDCARD);
|
||||
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
|
||||
});
|
||||
|
||||
it('navigates an absolute path under its root', () => {
|
||||
const args = buildGlobSearchArgs('/usr/local/bin', '/home', DEPTH);
|
||||
expect(args.path).toBe('/usr/local');
|
||||
expect(args.include).toBe(buildCaseInsensitiveGlob('bin'));
|
||||
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
|
||||
expect(args.rankQuery).toBe('bin');
|
||||
expect(args.last).toBe('bin');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user