Compare commits

..

9 Commits

Author SHA1 Message Date
Rafail Giavrimis 687e778927 CUDA: fuse rms_norm + mul + rope (+ view + set_rows) (#26767)
* CUDA: fuse rms_norm + mul + rope (+ view + set_rows)

* tests: add broadcast weight case to rms_norm_mul_rope

* CUDA: check memory ranges before rms_norm rope fusion

* CUDA: check memory ranges in rope set_rows fusion
2026-08-09 00:32:37 +08:00
Pascal 18f7ad7fc9 server, ui: only offer a working directory when a tool reads it (#26762)
The working directory chip showed up as soon as the server exposed any
builtin tool, so a server started with just get_datetime, or a user who
turned every filesystem tool off in the settings, still got a control
that nothing would read.

Tools now declare whether they resolve their paths and run against the
working directory, next to the write permission they already publish in
the /tools listing. The WebUI shows the chip and enables the /cwd
command only when at least one such tool is both served and left
enabled.
2026-08-08 16:36:21 +02:00
Xuan-Son Nguyen dd2c7c4471 server: add initial tool isolation support (via docker) (#26507)
* server: add initial tool isolation support (via docker)

* add docs

* adapt get_info

* py: fix type check

* cont

* separate tools_io_sandbox / tools_io_docker

* rename sandbox --> isolate

* x-tool-docker --> x-tool-runtime

---------

Co-authored-by: Pascal <admin@serveurperso.com>
2026-08-08 16:35:53 +02:00
Rafail Giavrimis 69bf643791 CUDA: fix thread/block count in quantized cpy kernel launches (#26731)
* CUDA: fix thread/block count in quantized cpy kernel launches

* tests: add uneven block count cpy case
2026-08-08 07:40:04 +03:00
Pascal 3653e6d6d5 tts: account for the vocoder pass in the timings line (#26733)
get_output runs the waveform work the pipeline defers to it, from a
single trailing window to a full pass depending on the model. Measuring
it keeps the reported total and the audio to process ratio honest.
2026-08-07 22:35:52 +02:00
Aleksander Grygier fc6545d322 allozaur/feat/chat form contenteditable (#26717)
* feat: Add contenteditable tokenizer for badge/code-chip chat input

* feat: Add source-space undo/redo history for the rich input

* feat: Split text glued to a closing code fence onto its own line

* feat: Add ChatFormContenteditable rich input renderer

* feat : wire the contenteditable into ChatForm with auto-switch gating
2026-08-07 20:40:10 +02:00
Georgi Gerganov 1621a3d388 tests : speed-up server test suite 3x (#26734)
* tests : speed-up test suite 3x

* cont : print 30 slowest tests
2026-08-07 21:38:32 +03:00
Aleksander Grygier 6de1b63473 allozaur/feat/chat slash commands (#26716)
* base : slash-command/misc foundation - model icon and focus-selector constants

* feat : slash-command picker and command parsing helpers

* refactor : wire command and @-mention pickers into the chat form

* ui : improve model selector keyboard navigation and load/dismiss

* feat: Unify markdown/raw-text rendering under one setting with migration

* fix: Misc fixes - tool-call subtitle, assistant wrap, progress guards

* feat: Clamp and style numeric settings inputs from registry bounds
2026-08-07 20:20:01 +02:00
Titaniumtown f8e30266d2 sycl: coalesce the ssm_conv window loads (#26612)
test-backend-ops perf -o SSM_CONV on an Arc Pro B70, interleaved A/B against
master, 6 reps, us/run:

  ne_a=[515,3328,1,1] ne_b=[4,3328,1,1]   n_t=512     97.68 -> 52.95   1.85x
  ne_a=[937,8192,1,1] ne_b=[4,8192,1,1]   n_t=934    516.16 -> 276.13  1.87x
  ne_a=[4,3328,1,1]   ne_b=[4,3328,1,1]   n_t=1        2.73 -> 2.71    flat

llama-bench on qwen35 27B Q4_K - Medium (48 of its 64 blocks run ssm_conv),
-ngl 99 -fa 1 -ctk f16 -ctv f16, interleaved passes of r=3:

  -b 2048 -ub 2048  pp2048  1045.1 / 1043.5 / 1043.7 -> 1069.5 / 1066.3 / 1065.9  +2.2%
  -b 2048 -ub 512   pp2048   771.8 /  772.7          ->  785.5 /  786.6           +1.8%
  -b 2048 -ub 512   tg128     23.81 /  23.88         ->   23.87 /  23.86          flat
2026-08-07 21:09:32 +03:00
86 changed files with 5944 additions and 733 deletions
+10
View File
@@ -3308,6 +3308,16 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
params.server_tools = parse_csv_row(value);
}
).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_TOOLS"));
add_opt(common_arg(
{"--tools-runtime"}, "OPTION",
"experimental: run tools in a separate runtime environment (default: none, use host environment)\n"
"available options:\n"
" 'docker:<image>': spin up a new Docker container and reuse it for all invocations, clean up on server exit\n"
" 'docker-container:<id>': use an existing Docker container by ID, won't stop on server exit\n",
[](common_params & params, const std::string & value) {
params.server_tools_runtime = value;
}
).set_examples({LLAMA_EXAMPLE_SERVER}).set_env("LLAMA_ARG_TOOLS_RUNTIME"));
add_opt(common_arg(
{"--mcp-servers-config"}, "PATH",
"experimental: path to JSON file with MCP server definitions (Cursor-compatible format) - do not enable in untrusted environments (default: none)\n"
+1
View File
@@ -655,6 +655,7 @@ struct common_params {
// enable built-in tools
std::vector<std::string> server_tools;
std::string server_tools_runtime;
// MCP server configs (Cursor-compatible JSON)
std::string mcp_servers_config; // path to JSON file with MCP server definitions
+22 -22
View File
@@ -253,9 +253,9 @@ static void ggml_cpy_f32_q8_0_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) {
GGML_ASSERT(ne % QK8_0 == 0);
const int64_t num_blocks = ne / QK8_0;
const int64_t num_blocks = (ne/QK8_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_f32_q<cpy_blck_f32_q8_0, QK8_0><<<num_blocks, 1, 0, stream>>>
cpy_f32_q<cpy_blck_f32_q8_0, QK8_0><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>
(cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -264,9 +264,9 @@ static void ggml_cpy_q8_0_f32_cuda(
const int64_t ne00, const int64_t ne01, const int64_t ne02, const int64_t nb00, const int64_t nb01, const int64_t nb02,
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) {
const int64_t num_blocks = ne;
const int64_t num_blocks = (ne/QK8_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_q_f32<cpy_blck_q8_0_f32, QK8_0><<<num_blocks, 1, 0, stream>>>
cpy_q_f32<cpy_blck_q8_0_f32, QK8_0><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>
(cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -276,9 +276,9 @@ static void ggml_cpy_f32_q4_0_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) {
GGML_ASSERT(ne % QK4_0 == 0);
const int64_t num_blocks = ne / QK4_0;
const int64_t num_blocks = (ne/QK4_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_f32_q<cpy_blck_f32_q4_0, QK4_0><<<num_blocks, 1, 0, stream>>>
cpy_f32_q<cpy_blck_f32_q4_0, QK4_0><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>
(cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -289,9 +289,9 @@ static void ggml_cpy_q4_0_f32_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12,
const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13,
cudaStream_t stream) {
const int64_t num_blocks = ne;
const int64_t num_blocks = (ne/QK4_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_q_f32<cpy_blck_q_f32<dequantize_q4_0, QK4_0>, QK4_0><<<num_blocks, 1, 0, stream>>>(
cpy_q_f32<cpy_blck_q_f32<dequantize_q4_0, QK4_0>, QK4_0><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>(
cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03,
ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -302,9 +302,9 @@ static void ggml_cpy_f32_q4_1_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) {
GGML_ASSERT(ne % QK4_1 == 0);
const int64_t num_blocks = ne / QK4_1;
const int64_t num_blocks = (ne/QK4_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_f32_q<cpy_blck_f32_q4_1, QK4_1><<<num_blocks, 1, 0, stream>>>
cpy_f32_q<cpy_blck_f32_q4_1, QK4_1><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>
(cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -315,9 +315,9 @@ static void ggml_cpy_q4_1_f32_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12,
const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13,
cudaStream_t stream) {
const int64_t num_blocks = ne;
const int64_t num_blocks = (ne/QK4_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_q_f32<cpy_blck_q_f32<dequantize_q4_1, QK4_1>, QK4_1><<<num_blocks, 1, 0, stream>>>(
cpy_q_f32<cpy_blck_q_f32<dequantize_q4_1, QK4_1>, QK4_1><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>(
cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03,
ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -328,9 +328,9 @@ static void ggml_cpy_f32_q5_0_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) {
GGML_ASSERT(ne % QK5_0 == 0);
const int64_t num_blocks = ne / QK5_0;
const int64_t num_blocks = (ne/QK5_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_f32_q<cpy_blck_f32_q5_0, QK5_0><<<num_blocks, 1, 0, stream>>>
cpy_f32_q<cpy_blck_f32_q5_0, QK5_0><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>
(cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -341,9 +341,9 @@ static void ggml_cpy_q5_0_f32_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12,
const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13,
cudaStream_t stream) {
const int64_t num_blocks = ne;
const int64_t num_blocks = (ne/QK5_0 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_q_f32<cpy_blck_q_f32<dequantize_q5_0, QK5_0>, QK5_0><<<num_blocks, 1, 0, stream>>>(
cpy_q_f32<cpy_blck_q_f32<dequantize_q5_0, QK5_0>, QK5_0><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>(
cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03,
ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -354,9 +354,9 @@ static void ggml_cpy_f32_q5_1_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) {
GGML_ASSERT(ne % QK5_1 == 0);
const int64_t num_blocks = ne / QK5_1;
const int64_t num_blocks = (ne/QK5_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_f32_q<cpy_blck_f32_q5_1, QK5_1><<<num_blocks, 1, 0, stream>>>
cpy_f32_q<cpy_blck_f32_q5_1, QK5_1><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>
(cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -367,9 +367,9 @@ static void ggml_cpy_q5_1_f32_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12,
const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13,
cudaStream_t stream) {
const int64_t num_blocks = ne;
const int64_t num_blocks = (ne/QK5_1 + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_q_f32<cpy_blck_q_f32<dequantize_q5_1, QK5_1>, QK5_1><<<num_blocks, 1, 0, stream>>>(
cpy_q_f32<cpy_blck_q_f32<dequantize_q5_1, QK5_1>, QK5_1><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>(
cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03,
ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
@@ -380,9 +380,9 @@ static void ggml_cpy_f32_iq4_nl_cuda(
const int64_t nb03, const int64_t ne10, const int64_t ne11, const int64_t ne12, const int64_t nb10, const int64_t nb11, const int64_t nb12, const int64_t nb13, cudaStream_t stream) {
GGML_ASSERT(ne % QK4_NL == 0);
const int64_t num_blocks = ne / QK4_NL;
const int64_t num_blocks = (ne/QK4_NL + CUDA_CPY_BLOCK_SIZE - 1) / CUDA_CPY_BLOCK_SIZE;
GGML_ASSERT(num_blocks <= INT_MAX);
cpy_f32_q<cpy_blck_f32_iq4_nl, QK4_NL><<<num_blocks, 1, 0, stream>>>
cpy_f32_q<cpy_blck_f32_iq4_nl, QK4_NL><<<num_blocks, CUDA_CPY_BLOCK_SIZE, 0, stream>>>
(cx, cdst, ne, ne00, ne01, ne02, nb00, nb01, nb02, nb03, ne10, ne11, ne12, nb10, nb11, nb12, nb13);
}
+88 -1
View File
@@ -2651,6 +2651,52 @@ static bool ggml_cuda_should_fuse_rope_set_rows(const ggml_tensor * rope,
return true;
}
static bool ggml_cuda_should_fuse_rms_norm_mul_rope(const ggml_tensor * rms_norm,
const ggml_tensor * mul,
const ggml_tensor * rope) {
if (rms_norm->op != GGML_OP_RMS_NORM || mul->op != GGML_OP_MUL || rope->op != GGML_OP_ROPE) {
return false;
}
if (rms_norm->src[0]->type != GGML_TYPE_F32 || rms_norm->type != GGML_TYPE_F32 ||
mul->src[0]->type != GGML_TYPE_F32 || mul->src[1]->type != GGML_TYPE_F32 ||
mul->type != GGML_TYPE_F32 || rope->type != GGML_TYPE_F32) {
return false;
}
if (rope->src[0] != mul) {
return false;
}
//if rms norm is the B operand, then we don't handle broadcast
if (rms_norm == mul->src[1] && !ggml_are_same_shape(mul->src[0], rms_norm)) {
return false;
}
if (!ggml_are_same_shape(rms_norm, mul)) {
return false;
}
//rms_norm kernel assumes contiguous rows
if (!ggml_is_contiguous_rows(rms_norm->src[0]) ||
!ggml_is_contiguous_rows(mul->src[0]) || !ggml_is_contiguous_rows(mul->src[1])) {
return false;
}
// the fused kernel handles the norm/neox rope modes only
const int mode = ((const int32_t *) rope->op_params)[2];
if (mode != GGML_ROPE_TYPE_NORMAL && mode != GGML_ROPE_TYPE_NEOX) {
return false;
}
const int n_dims = ((const int32_t *) rope->op_params)[1];
if (n_dims % 2 != 0 || rope->src[0]->ne[0] % 2 != 0) {
return false;
}
return true;
}
// match gated_delta_net + the strided cpy that scatters its state snapshots into the cache
// (slot i -> rollback group i, slot 0 newest), so the kernel can write them and skip the cpy.
static int ggml_cuda_try_gdn_cache_fusion(
@@ -2980,6 +3026,36 @@ static bool ggml_cuda_can_fuse(const struct ggml_cgraph * cgraph,
}
}
std::initializer_list<enum ggml_op> rms_norm_mul_rope_ops = { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE };
std::initializer_list<enum ggml_op> rms_norm_mul_rope_set_rows_ops = { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE, GGML_OP_VIEW, GGML_OP_SET_ROWS };
if (is_equal(rms_norm_mul_rope_set_rows_ops, ops) && ggml_can_fuse_subgraph(cgraph, node_idx, ops, { node_idx + 4 })) {
const ggml_tensor * rms_norm = cgraph->nodes[node_idx];
const ggml_tensor * mul = cgraph->nodes[node_idx + 1];
const ggml_tensor * rope = cgraph->nodes[node_idx + 2];
const ggml_tensor * view = cgraph->nodes[node_idx + 3];
const ggml_tensor * set_rows = cgraph->nodes[node_idx + 4];
if (ggml_check_edges(cgraph, node_idx, {{1, 0, 0}, {2, 0, 1}, {3, 0, 2}, {4, 0, 3}}) &&
ggml_cuda_should_fuse_rms_norm_mul_rope(rms_norm, mul, rope) &&
ggml_cuda_should_fuse_rope_set_rows(rope, view, set_rows)) {
int out_nodes[] = { node_idx + 4 };
return ggml_cuda_check_fusion_memory_ranges(cgraph, node_idx, (int)ops.size(), out_nodes, 1);
}
}
if (is_equal(rms_norm_mul_rope_ops, ops) && ggml_can_fuse(cgraph, node_idx, ops)) {
const ggml_tensor * rms_norm = cgraph->nodes[node_idx];
const ggml_tensor * mul = cgraph->nodes[node_idx + 1];
const ggml_tensor * rope = cgraph->nodes[node_idx + 2];
if (ggml_cuda_should_fuse_rms_norm_mul_rope(rms_norm, mul, rope)) {
int out_nodes[] = { node_idx + 2 };
return ggml_cuda_check_fusion_memory_ranges(cgraph, node_idx, (int)ops.size(), out_nodes, 1);
}
return false;
}
std::initializer_list<enum ggml_op> rope_set_rows_ops = { GGML_OP_ROPE, GGML_OP_VIEW, GGML_OP_SET_ROWS };
if (is_equal(rope_set_rows_ops, ops) && ggml_can_fuse_subgraph(cgraph, node_idx, ops, { node_idx + 2 })) {
@@ -2988,7 +3064,8 @@ static bool ggml_cuda_can_fuse(const struct ggml_cgraph * cgraph,
const ggml_tensor * set_rows = cgraph->nodes[node_idx + 2];
if (ggml_cuda_should_fuse_rope_set_rows(rope, view, set_rows)) {
return true;
int out_nodes[] = { node_idx + 2 };
return ggml_cuda_check_fusion_memory_ranges(cgraph, node_idx, (int)ops.size(), out_nodes, 1);
}
}
@@ -3840,6 +3917,16 @@ static int ggml_cuda_try_fuse(ggml_backend_cuda_context * cuda_ctx, ggml_cgraph
return fused_node_count - 1;
}
if (ggml_cuda_can_fuse(cgraph, i, { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE, GGML_OP_VIEW, GGML_OP_SET_ROWS }, {})) {
ggml_cuda_op_rms_norm_mul_rope_fused(*cuda_ctx, node, cgraph->nodes[i + 1], cgraph->nodes[i + 2], cgraph->nodes[i + 4]);
return 4;
}
if (ggml_cuda_can_fuse(cgraph, i, { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ROPE }, {})) {
ggml_cuda_op_rms_norm_mul_rope_fused(*cuda_ctx, node, cgraph->nodes[i + 1], cgraph->nodes[i + 2], nullptr);
return 2;
}
if (ggml_cuda_can_fuse(cgraph, i, { GGML_OP_RMS_NORM, GGML_OP_MUL, GGML_OP_ADD }, {})) {
ggml_cuda_op_rms_norm_fused_add(*cuda_ctx, node, cgraph->nodes[i + 1], cgraph->nodes[i + 2]);
return 2;
+235
View File
@@ -670,3 +670,238 @@ void ggml_cuda_op_rope_back(ggml_backend_cuda_context & ctx, ggml_tensor * dst)
void ggml_cuda_op_rope_fused(ggml_backend_cuda_context & ctx, ggml_tensor * rope, ggml_tensor * set_rows) {
ggml_cuda_op_rope_impl<true>(ctx, rope, set_rows);
}
// fused RMS_NORM + MUL + ROPE (+ VIEW + SET_ROWS)
// one block per row: block_reduce gives the norm scale, then each thread applies mul and rope to the elements it owns
template <int block_size, bool has_ff, typename D>
static __global__ void rms_norm_mul_rope_f32(
const float * x, D * dst, const int ncols,
const int64_t s01, const int64_t s02, const int64_t s03,
const int64_t s1, const int64_t s2, const int64_t s3,
const float eps,
const float * mul,
const int64_t mul_s01, const int64_t mul_s02, const int64_t mul_s03,
const uint3 mul_ncols_packed, const uint3 mul_nrows_packed,
const uint3 mul_nchannels_packed, const uint3 mul_nsamples_packed,
const int n_dims, const int32_t * pos,
const float freq_scale, const float ext_factor, const float attn_factor,
const rope_corr_dims corr_dims, const float theta_scale,
const float * freq_factors,
const int64_t * row_indices, const int set_rows_stride,
const bool is_neox) {
ggml_cuda_pdl_lc();
const int row = blockIdx.x;
const int channel = blockIdx.y;
const int sample = blockIdx.z;
const int tid = threadIdx.x;
x += sample*s03 + channel*s02 + row*s01;
const uint32_t mul_row = fastmodulo(row, mul_nrows_packed);
const uint32_t mul_channel = fastmodulo(channel, mul_nchannels_packed);
const uint32_t mul_sample = fastmodulo(sample, mul_nsamples_packed);
mul += mul_sample*mul_s03 + mul_channel*mul_s02 + mul_row*mul_s01;
float tmp = 0.0f;
ggml_cuda_pdl_sync();
for (int col = tid; col < ncols; col += block_size) {
const float xi = x[col];
tmp += xi * xi;
}
extern __shared__ float s_sum[];
tmp = block_reduce<block_reduce_method::SUM, block_size>(tmp, s_sum);
const float scale = rsqrtf(tmp/ncols + eps);
int64_t idst = sample*s3 + channel*s2 + row*s1;
if (set_rows_stride != 0) {
idst = row*s1 + row_indices[channel]*set_rows_stride;
}
dst += idst;
for (int i0 = 2*tid; i0 < ncols; i0 += 2*block_size) {
int ix0;
int ix1;
if (is_neox && i0 < n_dims) {
ix0 = i0/2;
ix1 = i0/2 + n_dims/2;
} else {
ix0 = i0 + 0;
ix1 = i0 + 1;
}
const float x0 = scale * x[ix0] * mul[fastmodulo(ix0, mul_ncols_packed)];
const float x1 = scale * x[ix1] * mul[fastmodulo(ix1, mul_ncols_packed)];
if (i0 >= n_dims) {
dst[ix0] = ggml_cuda_cast<D>(x0);
dst[ix1] = ggml_cuda_cast<D>(x1);
continue;
}
const float theta_base = pos[channel]*powf(theta_scale, i0/2.0f);
const float freq_factor = has_ff ? freq_factors[i0/2] : 1.0f;
float cos_theta;
float sin_theta;
rope_yarn<true>(theta_base/freq_factor, freq_scale, corr_dims, i0, ext_factor, attn_factor, cos_theta, sin_theta);
dst[ix0] = ggml_cuda_cast<D>(x0*cos_theta - x1*sin_theta);
dst[ix1] = ggml_cuda_cast<D>(x0*sin_theta + x1*cos_theta);
}
}
template <typename D>
static void rms_norm_mul_rope_cuda(
const float * x, D * dst,
const int ncols, const int nrows, const int nchannels, const int nsamples,
const int64_t s01, const int64_t s02, const int64_t s03,
const int64_t s1, const int64_t s2, const int64_t s3,
const float eps,
const float * mul,
const int64_t mul_s01, const int64_t mul_s02, const int64_t mul_s03,
const uint32_t mul_ncols, const uint32_t mul_nrows,
const uint32_t mul_nchannels, const uint32_t mul_nsamples,
const int n_dims, const int32_t * pos,
const float freq_scale, const float freq_base, const float ext_factor, const float attn_factor,
const rope_corr_dims corr_dims,
const float * freq_factors,
const int64_t * row_indices, const int set_rows_stride,
const bool is_neox, cudaStream_t stream) {
GGML_ASSERT(ncols % 2 == 0);
const dim3 blocks_num(nrows, nchannels, nsamples);
const float theta_scale = powf(freq_base, -2.0f/n_dims);
const uint3 mul_ncols_packed = init_fastdiv_values(mul_ncols);
const uint3 mul_nrows_packed = init_fastdiv_values(mul_nrows);
const uint3 mul_nchannels_packed = init_fastdiv_values(mul_nchannels);
const uint3 mul_nsamples_packed = init_fastdiv_values(mul_nsamples);
if (ncols < 1024) {
const dim3 block_dims(256, 1, 1);
const ggml_cuda_kernel_launch_params launch_params = {blocks_num, block_dims, 32*sizeof(float), stream};
if (freq_factors == nullptr) {
ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<256, false, D>, launch_params,
x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03,
mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed,
n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale,
freq_factors, row_indices, set_rows_stride, is_neox);
} else {
ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<256, true, D>, launch_params,
x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03,
mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed,
n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale,
freq_factors, row_indices, set_rows_stride, is_neox);
}
} else {
const dim3 block_dims(1024, 1, 1);
const ggml_cuda_kernel_launch_params launch_params = {blocks_num, block_dims, 32*sizeof(float), stream};
if (freq_factors == nullptr) {
ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<1024, false, D>, launch_params,
x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03,
mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed,
n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale,
freq_factors, row_indices, set_rows_stride, is_neox);
} else {
ggml_cuda_kernel_launch(rms_norm_mul_rope_f32<1024, true, D>, launch_params,
x, dst, ncols, s01, s02, s03, s1, s2, s3, eps, mul, mul_s01, mul_s02, mul_s03,
mul_ncols_packed, mul_nrows_packed, mul_nchannels_packed, mul_nsamples_packed,
n_dims, pos, freq_scale, ext_factor, attn_factor, corr_dims, theta_scale,
freq_factors, row_indices, set_rows_stride, is_neox);
}
}
}
void ggml_cuda_op_rms_norm_mul_rope_fused(ggml_backend_cuda_context & ctx,
ggml_tensor * rms_norm, ggml_tensor * mul, ggml_tensor * rope, ggml_tensor * set_rows) {
const ggml_tensor * x = rms_norm->src[0];
const ggml_tensor * mul_src = mul->src[0] == rms_norm ? mul->src[1] : mul->src[0];
float eps = 0.0f;
memcpy(&eps, rms_norm->op_params, sizeof(float));
GGML_ASSERT(eps >= 0.0f);
GGML_ASSERT(x->type == GGML_TYPE_F32);
GGML_ASSERT(mul_src->type == GGML_TYPE_F32);
GGML_ASSERT(rope->type == GGML_TYPE_F32);
void * dst_d = rope->data;
ggml_type dst_type = rope->type;
const int64_t * row_indices = nullptr;
int set_rows_stride = 0;
if (set_rows != nullptr) {
dst_d = set_rows->data;
dst_type = set_rows->type;
row_indices = (const int64_t *) set_rows->src[1]->data;
set_rows_stride = set_rows->nb[1] / ggml_type_size(set_rows->type);
}
const int n_dims = ((const int32_t *) rope->op_params)[1];
const int mode = ((const int32_t *) rope->op_params)[2];
const int n_ctx_orig = ((const int32_t *) rope->op_params)[4];
float freq_base;
float freq_scale;
float ext_factor;
float attn_factor;
float beta_fast;
float beta_slow;
memcpy(&freq_base, (const int32_t *) rope->op_params + 5, sizeof(float));
memcpy(&freq_scale, (const int32_t *) rope->op_params + 6, sizeof(float));
memcpy(&ext_factor, (const int32_t *) rope->op_params + 7, sizeof(float));
memcpy(&attn_factor, (const int32_t *) rope->op_params + 8, sizeof(float));
memcpy(&beta_fast, (const int32_t *) rope->op_params + 9, sizeof(float));
memcpy(&beta_slow, (const int32_t *) rope->op_params + 10, sizeof(float));
const bool is_neox = mode & GGML_ROPE_TYPE_NEOX;
const int32_t * pos = (const int32_t *) rope->src[1]->data;
const float * freq_factors = rope->src[2] != nullptr ? (const float *) rope->src[2]->data : nullptr;
rope_corr_dims corr_dims;
ggml_rope_yarn_corr_dims(n_dims, n_ctx_orig, freq_base, beta_fast, beta_slow, corr_dims.v);
const size_t ts0 = ggml_type_size(x->type);
GGML_ASSERT(x->nb[0] == ts0);
const int64_t s01 = x->nb[1] / ts0;
const int64_t s02 = x->nb[2] / ts0;
const int64_t s03 = x->nb[3] / ts0;
const size_t ts_mul = ggml_type_size(mul_src->type);
GGML_ASSERT(mul_src->nb[0] == ts_mul);
const int64_t mul_s01 = mul_src->nb[1] / ts_mul;
const int64_t mul_s02 = mul_src->nb[2] / ts_mul;
const int64_t mul_s03 = mul_src->nb[3] / ts_mul;
const size_t ts_dst = ggml_type_size(rope->type);
const int64_t s1 = rope->nb[1] / ts_dst;
const int64_t s2 = rope->nb[2] / ts_dst;
const int64_t s3 = rope->nb[3] / ts_dst;
cudaStream_t stream = ctx.stream();
if (dst_type == GGML_TYPE_F32) {
rms_norm_mul_rope_cuda((const float *) x->data, (float *) dst_d,
x->ne[0], x->ne[1], x->ne[2], x->ne[3], s01, s02, s03, s1, s2, s3, eps,
(const float *) mul_src->data, mul_s01, mul_s02, mul_s03,
mul_src->ne[0], mul_src->ne[1], mul_src->ne[2], mul_src->ne[3],
n_dims, pos, freq_scale, freq_base, ext_factor, attn_factor, corr_dims,
freq_factors, row_indices, set_rows_stride, is_neox, stream);
} else if (dst_type == GGML_TYPE_F16) {
rms_norm_mul_rope_cuda((const float *) x->data, (half *) dst_d,
x->ne[0], x->ne[1], x->ne[2], x->ne[3], s01, s02, s03, s1, s2, s3, eps,
(const float *) mul_src->data, mul_s01, mul_s02, mul_s03,
mul_src->ne[0], mul_src->ne[1], mul_src->ne[2], mul_src->ne[3],
n_dims, pos, freq_scale, freq_base, ext_factor, attn_factor, corr_dims,
freq_factors, row_indices, set_rows_stride, is_neox, stream);
} else {
GGML_ABORT("fatal error");
}
}
+2
View File
@@ -7,3 +7,5 @@ void ggml_cuda_op_rope(ggml_backend_cuda_context & ctx, ggml_tensor * dst);
void ggml_cuda_op_rope_back(ggml_backend_cuda_context & ctx, ggml_tensor * dst);
void ggml_cuda_op_rope_fused(ggml_backend_cuda_context & ctx, ggml_tensor * dst, ggml_tensor * set_rows);
void ggml_cuda_op_rms_norm_mul_rope_fused(ggml_backend_cuda_context & ctx, ggml_tensor * rms_norm, ggml_tensor * mul, ggml_tensor * rope, ggml_tensor * set_rows);
+7 -3
View File
@@ -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)
+22 -14
View File
@@ -2584,6 +2584,7 @@ struct test_rms_norm_mul_rope : public test_case {
const float eps;
const bool multi_add; // test a sequence of adds feeding into rms_norm
const bool set_rows;
const bool broadcast; // multiply by a 1D [ne0] weight, as model norm weights are
int mode;
std::string op_desc(ggml_tensor * t) override {
@@ -2594,12 +2595,12 @@ struct test_rms_norm_mul_rope : public test_case {
bool run_whole_graph() override { return true; }
std::string vars() override {
return VARS_TO_STR5(ne, eps, multi_add, set_rows, mode);
return VARS_TO_STR6(ne, eps, multi_add, set_rows, broadcast, mode);
}
test_rms_norm_mul_rope(std::array<int64_t, 4> ne, float eps = 1e-6f, bool multi_add = false,
bool set_rows = false, int mode = GGML_ROPE_TYPE_NORMAL)
: ne(ne), eps(eps), multi_add(multi_add), set_rows(set_rows), mode(mode) {}
bool set_rows = false, bool broadcast = false, int mode = GGML_ROPE_TYPE_NORMAL)
: ne(ne), eps(eps), multi_add(multi_add), set_rows(set_rows), broadcast(broadcast), mode(mode) {}
ggml_tensor * build_graph(ggml_context * ctx) override {
ggml_tensor * a = ggml_new_tensor_4d(ctx, GGML_TYPE_F32, ne[0], ne[1], ne[2], 1);
@@ -2610,7 +2611,9 @@ struct test_rms_norm_mul_rope : public test_case {
a = ggml_add(ctx, ggml_add(ctx, a, b), c);
}
a = ggml_mul(ctx, ggml_rms_norm(ctx, a, eps), b);
ggml_tensor * w = broadcast ? ggml_new_tensor_1d(ctx, GGML_TYPE_F32, ne[0]) : b;
a = ggml_mul(ctx, ggml_rms_norm(ctx, a, eps), w);
ggml_tensor * pos = ggml_new_tensor_1d(ctx, GGML_TYPE_I32, ne[2]);
@@ -8576,6 +8579,9 @@ static std::vector<std::unique_ptr<test_case>> make_test_cases_eval() {
test_cases.emplace_back(new test_cpy(type_src, type_dst, {256, 2, 3, 4}, {-1,-1,-1,-1}, {1, 0, 2, 3})); // cpy not-contiguous
}
}
// quant block count not a multiple of the kernel block size
test_cases.emplace_back(new test_cpy(GGML_TYPE_F32, GGML_TYPE_Q4_0, {96, 1, 1, 1}));
test_cases.emplace_back(new test_cpy(GGML_TYPE_Q4_0, GGML_TYPE_F32, {96, 1, 1, 1}));
test_cases.emplace_back(new test_cpy(GGML_TYPE_F32, GGML_TYPE_I32, {256, 2, 3, 4}));
test_cases.emplace_back(new test_cpy(GGML_TYPE_F32, GGML_TYPE_I32, {256, 2, 3, 4}, {-1,-1,-1,-1}, {1, 0, 2, 3}));
test_cases.emplace_back(new test_cpy(GGML_TYPE_I32, GGML_TYPE_F32, {256, 2, 3, 4}));
@@ -8753,16 +8759,18 @@ static std::vector<std::unique_ptr<test_case>> make_test_cases_eval() {
for (auto multi_add : {false, true}) {
for (auto set_rows : {false, true}) {
for (auto rope : {GGML_ROPE_TYPE_NORMAL, GGML_ROPE_TYPE_NEOX}) {
test_cases.emplace_back(new test_rms_norm_mul_rope({768, 1, 1, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({768, 3, 1, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({768, 3, 5, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 32, 2, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 4, 2, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 32, 50, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 4, 50, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({8192, 2, 2, 1}, 1e-6f, multi_add, set_rows, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({8192, 2, 2, 1}, 1e-6f, multi_add, set_rows, rope));
for (auto broadcast : {false, true}) {
for (auto rope : {GGML_ROPE_TYPE_NORMAL, GGML_ROPE_TYPE_NEOX}) {
test_cases.emplace_back(new test_rms_norm_mul_rope({768, 1, 1, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({768, 3, 1, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({768, 3, 5, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 32, 2, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 4, 2, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 32, 50, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({128, 4, 50, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({8192, 2, 2, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
test_cases.emplace_back(new test_rms_norm_mul_rope({8192, 2, 2, 1}, 1e-6f, multi_add, set_rows, broadcast, rope));
}
}
}
}
-1
View File
@@ -54,7 +54,6 @@
| `-ctv, --cache-type-v TYPE` | KV cache data type for V<br/>allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1<br/>(default: f16)<br/>(env: LLAMA_ARG_CACHE_TYPE_V) |
| `-dt, --defrag-thold N` | KV cache defragmentation threshold (DEPRECATED)<br/>(env: LLAMA_ARG_DEFRAG_THOLD) |
| `-np, --parallel N` | number of parallel sequences to decode (default: 1)<br/>(env: LLAMA_ARG_N_PARALLEL) |
| `--rpc SERVERS` | comma-separated list of RPC servers (host:port)<br/>(env: LLAMA_ARG_RPC) |
| `--mlock` | DEPRECATED in favor of `--load-mode`: force system to keep model in RAM rather than swapping or compressing<br/>(env: LLAMA_ARG_MLOCK) |
| `--mmap, --no-mmap` | DEPRECATED in favor of `--load-mode`: whether to memory-map model. (if mmap disabled, slower load but may reduce pageouts if not using mlock)<br/>(env: LLAMA_ARG_MMAP) |
| `-dio, --direct-io, -ndio, --no-direct-io` | DEPRECATED in favor of `--load-mode`: use DirectIO if available<br/>(env: LLAMA_ARG_DIO) |
-1
View File
@@ -137,7 +137,6 @@ llama-completion.exe -m models\gemma-1.1-7b-it.Q4_K_M.gguf --ignore-eos -n -1
| `-ctv, --cache-type-v TYPE` | KV cache data type for V<br/>allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1<br/>(default: f16)<br/>(env: LLAMA_ARG_CACHE_TYPE_V) |
| `-dt, --defrag-thold N` | KV cache defragmentation threshold (DEPRECATED)<br/>(env: LLAMA_ARG_DEFRAG_THOLD) |
| `-np, --parallel N` | number of parallel sequences to decode (default: 1)<br/>(env: LLAMA_ARG_N_PARALLEL) |
| `--rpc SERVERS` | comma-separated list of RPC servers (host:port)<br/>(env: LLAMA_ARG_RPC) |
| `--mlock` | DEPRECATED in favor of `--load-mode`: force system to keep model in RAM rather than swapping or compressing<br/>(env: LLAMA_ARG_MLOCK) |
| `--mmap, --no-mmap` | DEPRECATED in favor of `--load-mode`: whether to memory-map model. (if mmap disabled, slower load but may reduce pageouts if not using mlock)<br/>(env: LLAMA_ARG_MMAP) |
| `-dio, --direct-io, -ndio, --no-direct-io` | DEPRECATED in favor of `--load-mode`: use DirectIO if available<br/>(env: LLAMA_ARG_DIO) |
+1
View File
@@ -201,6 +201,7 @@ Invoke a tool call, request body is a JSON object with:
Headers:
- `x-tool-cwd`: optional; if set, use as the CWD for tool; this is not part of tool's params because it's meant to be set by the runtime, not the LLM itself
- `x-tool-runtime`: optional; if set, run the tool inside this isolate instead of on the host. Only `docker-container:<id>` is supported for now, using an already-running container
Returns JSON object. There are two response formats (MCP tools use the same two formats: their result content is concatenated into `plain_text_response`, and RPC or tool errors are surfaced as the `error` string):
+2 -1
View File
@@ -71,7 +71,6 @@ For the full list of features, please refer to [server's changelog](https://gith
| `-ctk, --cache-type-k TYPE` | KV cache data type for K<br/>allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1<br/>(default: f16)<br/>(env: LLAMA_ARG_CACHE_TYPE_K) |
| `-ctv, --cache-type-v TYPE` | KV cache data type for V<br/>allowed values: f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0, q5_1<br/>(default: f16)<br/>(env: LLAMA_ARG_CACHE_TYPE_V) |
| `-dt, --defrag-thold N` | KV cache defragmentation threshold (DEPRECATED)<br/>(env: LLAMA_ARG_DEFRAG_THOLD) |
| `--rpc SERVERS` | comma-separated list of RPC servers (host:port)<br/>(env: LLAMA_ARG_RPC) |
| `--mlock` | DEPRECATED in favor of `--load-mode`: force system to keep model in RAM rather than swapping or compressing<br/>(env: LLAMA_ARG_MLOCK) |
| `--mmap, --no-mmap` | DEPRECATED in favor of `--load-mode`: whether to memory-map model. (if mmap disabled, slower load but may reduce pageouts if not using mlock)<br/>(env: LLAMA_ARG_MMAP) |
| `-dio, --direct-io, -ndio, --no-direct-io` | DEPRECATED in favor of `--load-mode`: use DirectIO if available<br/>(env: LLAMA_ARG_DIO) |
@@ -198,6 +197,8 @@ For the full list of features, please refer to [server's changelog](https://gith
| `--ui-config, --webui-config JSON` | JSON that provides default UI settings (overrides UI defaults)<br/>(env: LLAMA_ARG_UI_CONFIG) |
| `--ui-config-file, --webui-config-file PATH` | JSON file that provides default UI settings (overrides UI defaults)<br/>(env: LLAMA_ARG_UI_CONFIG_FILE) |
| `--ui-mcp-proxy, --webui-mcp-proxy, --no-ui-mcp-proxy, --no-webui-mcp-proxy` | experimental: whether to enable MCP CORS proxy - do not enable in untrusted environments (default: disabled)<br/>(env: LLAMA_ARG_UI_MCP_PROXY) |
| `--tools TOOL1,TOOL2,...` | experimental: whether to enable built-in tools for AI agents - do not enable in untrusted environments (default: no tools)<br/>specify "all" to enable all tools<br/>available tools: read_file, file_glob_search, grep_search, exec_shell_command, write_file, edit_file, get_datetime<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_TOOLS) |
| `--tools-runtime OPTION` | experimental: run tools in a separate runtime environment (default: none, use host environment)<br/>available options:<br/> 'docker:<image>': spin up a new Docker container and reuse it for all invocations, clean up on server exit<br/> 'docker-container:<id>': use an existing Docker container by ID, won't stop on server exit<br/><br/>(env: LLAMA_ARG_TOOLS_RUNTIME) |
| `--tools TOOL1,TOOL2,...` | experimental: whether to enable built-in tools for AI agents - do not enable in untrusted environments (default: no tools)<br/>specify "all" to enable all tools<br/>available tools: read_file, file_glob_search, grep_search, exec_shell_command, write_file, edit_file, get_datetime, get_info<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_TOOLS) |
| `--mcp-servers-config PATH` | experimental: path to JSON file with MCP server definitions (Cursor-compatible format) - do not enable in untrusted environments (default: none)<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_MCP_SERVERS_CONFIG) |
| `--mcp-servers-json JSON` | experimental: inline JSON with MCP server definitions (Cursor-compatible format) - do not enable in untrusted environments (default: none)<br/>note: for security reasons, this will limit --cors-origins to localhost by default<br/>(env: LLAMA_ARG_MCP_SERVERS_JSON) |
+499 -76
View File
@@ -10,12 +10,15 @@
#include <ctime>
#include <atomic>
#include <cstring>
#include <cstdint>
#include <cstdlib>
#include <algorithm>
#include <iterator>
#include <unordered_set>
#include <tuple>
#include <functional>
#include <memory>
#include <mutex>
#if defined(_WIN32)
# ifndef NOMINMAX
@@ -71,6 +74,7 @@ json server_tool::to_json() const {
{"permissions", json{
{"write", permission_write}
}},
{"uses_cwd", uses_cwd},
{"definition", get_definition()},
};
}
@@ -127,6 +131,13 @@ static int entry_depth(const std::string & rel) {
return 1 + (int) std::count(rel.begin(), rel.end(), '/');
}
// directories that a listing reports but never descends into: they can be enormous
// lowercase only, the local walker case-folds a name before the lookup
static const char * const SERVER_TOOL_JUNK_DIR_NAMES[] = {
".git", ".svn", ".hg", "node_modules", "__pycache__",
".venv", "venv", "dist", "build", "target", ".cache", ".idea", ".vscode",
};
class tools_io {
public:
struct exec_result {
@@ -165,6 +176,85 @@ public:
const std::function<bool(const std::string &)> & on_chunk = nullptr) const = 0;
};
// shared subprocess execution helper, used by both the local and the docker-backed tools_io implementations.
// combine_stderr=false when the raw stdout bytes must not be tainted by stderr, e.g. reading file contents.
static tools_io::exec_result run_subprocess(
const std::vector<std::string> & args,
size_t max_output,
int timeout_secs,
const std::function<bool(const std::string &)> & on_chunk,
bool combine_stderr,
const std::string & cwd = "") {
tools_io::exec_result res;
common_subproc proc;
int options = subprocess_option_no_window
| subprocess_option_inherit_environment
| subprocess_option_search_user_path;
if (combine_stderr) {
options |= subprocess_option_combined_stdout_stderr;
}
if (!proc.create(args, options, {}, cwd.empty() ? nullptr : cwd.c_str())) {
res.output = "failed to spawn process";
return res;
}
std::atomic<bool> done{false};
std::atomic<bool> timed_out{false};
std::thread timeout_thread([&]() {
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_secs);
while (!done.load()) {
if (std::chrono::steady_clock::now() >= deadline) {
timed_out.store(true);
proc.terminate();
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
});
FILE * f = proc.stdout_file();
std::string output;
bool truncated = false;
if (f) {
char buf[4096];
while (fgets(buf, sizeof(buf), f) != nullptr) {
if (!truncated) {
size_t len = strlen(buf);
if (output.size() + len <= max_output) {
output.append(buf, len);
if (on_chunk && !on_chunk(console_output_to_utf8(std::string(buf, len)))) {
proc.terminate();
break;
}
} else {
size_t remaining = max_output - output.size();
output.append(buf, remaining);
if (on_chunk && remaining > 0) on_chunk(console_output_to_utf8(std::string(buf, remaining)));
truncated = true;
}
}
}
}
done.store(true);
if (timeout_thread.joinable()) {
timeout_thread.join();
}
res.exit_code = proc.join();
res.output = console_output_to_utf8(output);
res.timed_out = timed_out.load();
if (truncated) {
res.output += "\n[output truncated]";
}
return res;
}
class tools_io_basic : public tools_io {
public:
// cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
@@ -276,72 +366,7 @@ public:
size_t max_output,
int timeout_secs,
const std::function<bool(const std::string &)> & on_chunk = nullptr) const override {
exec_result res;
common_subproc proc;
int options = subprocess_option_no_window
| subprocess_option_combined_stdout_stderr
| subprocess_option_inherit_environment
| subprocess_option_search_user_path;
if (!proc.create(args, options, {}, cwd.empty() ? nullptr : cwd.c_str())) {
res.output = "failed to spawn process";
return res;
}
std::atomic<bool> done{false};
std::atomic<bool> timed_out{false};
std::thread timeout_thread([&]() {
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(timeout_secs);
while (!done.load()) {
if (std::chrono::steady_clock::now() >= deadline) {
timed_out.store(true);
proc.terminate();
return;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
});
FILE * f = proc.stdout_file();
std::string output;
bool truncated = false;
if (f) {
char buf[4096];
while (fgets(buf, sizeof(buf), f) != nullptr) {
if (!truncated) {
size_t len = strlen(buf);
if (output.size() + len <= max_output) {
output.append(buf, len);
if (on_chunk && !on_chunk(console_output_to_utf8(std::string(buf, len)))) {
proc.terminate();
break;
}
} else {
size_t remaining = max_output - output.size();
output.append(buf, remaining);
if (on_chunk && remaining > 0) on_chunk(console_output_to_utf8(std::string(buf, remaining)));
truncated = true;
}
}
}
}
done.store(true);
if (timeout_thread.joinable()) {
timeout_thread.join();
}
res.exit_code = proc.join();
res.output = console_output_to_utf8(output);
res.timed_out = timed_out.load();
if (truncated) {
res.output += "\n[output truncated]";
}
return res;
return run_subprocess(args, max_output, timeout_secs, on_chunk, /*combine_stderr=*/true, cwd);
}
private:
@@ -384,10 +409,8 @@ private:
}
static const std::unordered_set<std::string> & junk_dir_names() {
static const std::unordered_set<std::string> names = {
".git", ".svn", ".hg", "node_modules", "__pycache__",
".venv", "venv", "dist", "build", "target", ".cache", ".idea", ".vscode",
};
static const std::unordered_set<std::string> names(
std::begin(SERVER_TOOL_JUNK_DIR_NAMES), std::end(SERVER_TOOL_JUNK_DIR_NAMES));
return names;
}
@@ -450,9 +473,274 @@ private:
}
};
// timeout for auxiliary isolate calls (stat/mkdir/ls/cp helpers); exec_shell_command uses its own
// caller-controlled timeout instead, enforced separately in run()
static constexpr int SERVER_TOOL_ISOLATE_EXEC_TIMEOUT = 15; // seconds
static constexpr size_t SERVER_TOOL_ISOLATE_READ_FILE_MAX_SIZE = 64 * 1024 * 1024; // 64 MB
// runs every tools_io operation as a command inside an isolate: a container, a remote host, ...
// the isolate is created, mounted, and torn down externally by the caller
// it must provide a POSIX environment: sh, cat, wc, mkdir, dirname, find, timeout
class tools_io_isolate : public tools_io {
public:
// cwd, if non-empty, is used to resolve relative paths and as the working directory for run()
explicit tools_io_isolate(std::string cwd = "") : cwd(std::move(cwd)) {}
// resolves `path` against `cwd` if `path` is relative and `cwd` is set; otherwise returns `path` unchanged.
// isolate paths are always POSIX-style ('/'), regardless of host OS.
std::string resolve(const std::string & path) const override {
if (cwd.empty() || (!path.empty() && path[0] == '/')) {
return path;
}
return cwd + "/" + path;
}
bool is_directory(const std::string & path) const override {
return shell_test("-d", resolve(path));
}
bool is_regular_file(const std::string & path) const override {
return shell_test("-f", resolve(path));
}
bool file_size(const std::string & path, uintmax_t & out_size) const override {
auto res = exec({"sh", "-c", "wc -c < \"$1\"", "_", resolve(path)}, 64, true);
if (res.exit_code != 0 || res.timed_out) return false;
try {
size_t pos;
out_size = (uintmax_t) std::stoull(res.output, &pos);
} catch (...) {
return false;
}
return true;
}
bool read_file(const std::string & path, std::string & out) const override {
// combine_stderr=false: stderr must not be spliced into raw file bytes
auto res = exec({"cat", "--", resolve(path)}, SERVER_TOOL_ISOLATE_READ_FILE_MAX_SIZE, false);
if (res.exit_code != 0 || res.timed_out) return false;
out = res.output;
return true;
}
bool write_file(const std::string & path, const std::string & content) const override {
std::string abs_path = resolve(path);
std::error_code ec;
fs::path tmp_dir = fs::temp_directory_path(ec);
if (ec) return false;
static std::atomic<uint64_t> tmp_counter{0};
fs::path tmp = tmp_dir / string_format(
"llama-tools-io-isolate-%zu-%llu.tmp",
std::hash<std::thread::id>{}(std::this_thread::get_id()),
(unsigned long long) tmp_counter.fetch_add(1));
{
std::ofstream f(tmp, std::ios::binary);
if (!f) return false;
f << content;
if (!f) return false;
}
bool ok = shell_run({"sh", "-c", "mkdir -p \"$(dirname \"$1\")\"", "_", abs_path});
if (ok) {
ok = upload(tmp.string(), abs_path);
}
std::error_code rm_ec;
fs::remove(tmp, rm_ec);
return ok;
}
list_result list_entries(const std::string & base, int max_depth, list_kind kind) const override {
list_result out;
const std::string abs_base = resolve(base);
if (!is_directory(base)) {
out.err = "path does not exist or is not a directory";
return out;
}
// git ls-files cannot list directories; use the walker when they are requested
if (kind == list_kind::files) {
auto res = exec(
{"sh", "-c", "cd \"$1\" && git ls-files --cached --others --exclude-standard", "_", abs_base},
SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, true);
if (res.exit_code == 0 && !res.timed_out) {
for (const auto & rel : split_lines(res.output, /*strip_dot_slash=*/false)) {
if (max_depth > 0 && entry_depth(rel) > max_depth) continue;
out.entries.push_back({rel, false});
}
return out;
}
}
if (kind == list_kind::dirs || kind == list_kind::all) {
for (auto & rel : find_entries(abs_base, max_depth, /*dirs=*/true, out.truncated)) {
out.entries.push_back({std::move(rel), true});
}
}
if (kind == list_kind::files || kind == list_kind::all) {
for (auto & rel : find_entries(abs_base, max_depth, /*dirs=*/false, out.truncated)) {
out.entries.push_back({std::move(rel), false});
}
}
return out;
}
// wraps the command with an in-isolate `timeout`, since killing the host-side client
// does not kill the process tree running inside the isolate
exec_result run(
const std::vector<std::string> & args,
size_t max_output,
int timeout_secs,
const std::function<bool(const std::string &)> & on_chunk = nullptr) const override {
std::vector<std::string> inner = {"timeout", std::to_string(timeout_secs) + "s"};
inner.insert(inner.end(), args.begin(), args.end());
// small buffer over timeout_secs so the in-isolate `timeout` has a chance to exit cleanly
// before the host-side supervisory timeout forcibly kills the client
return run_subprocess(
build_argv(with_cwd(inner), /*needs_stdin=*/true),
max_output, timeout_secs + 5, on_chunk, true);
}
protected:
// wrap `inner` (a complete POSIX argv) into the host-side argv that runs it in the isolate
// a transport that re-parses its args in a remote shell (ssh) must join `inner` with shell_quote_join()
virtual std::vector<std::string> build_argv(const std::vector<std::string> & inner, bool needs_stdin) const = 0;
// copy a host file into the isolate, `isolate_path` is absolute and its parent already exists
virtual bool upload(const std::string & host_path, const std::string & isolate_path) const = 0;
// quote `argv` into a single string that a POSIX shell re-parses into exactly `argv`
static std::string shell_quote_join(const std::vector<std::string> & argv) {
std::string out;
for (const auto & arg : argv) {
if (!out.empty()) out += ' ';
out += '\'';
for (const char c : arg) {
// a single quote cannot be escaped inside single quotes: close, escape, reopen
if (c == '\'') out += "'\\''";
else out += c;
}
out += '\'';
}
return out;
}
private:
std::string cwd;
// set the working directory in the command itself, docker's `-w` has no equivalent on every transport
// auxiliary calls do not need this, they use the absolute paths from resolve()
std::vector<std::string> with_cwd(const std::vector<std::string> & inner) const {
if (cwd.empty()) {
return inner;
}
// 127 is what a shell reports for a command it could not run
std::vector<std::string> out = {"sh", "-c", "cd \"$1\" || exit 127; shift; exec \"$@\"", "_", cwd};
out.insert(out.end(), inner.begin(), inner.end());
return out;
}
exec_result exec(const std::vector<std::string> & inner, size_t max_output, bool combine_stderr) const {
return run_subprocess(
build_argv(inner, /*needs_stdin=*/false),
max_output, SERVER_TOOL_ISOLATE_EXEC_TIMEOUT, nullptr, combine_stderr);
}
bool shell_run(const std::vector<std::string> & inner) const {
auto res = exec(inner, 4096, true);
return res.exit_code == 0 && !res.timed_out;
}
bool shell_test(const char * flag, const std::string & path) const {
return shell_run({"sh", "-c", std::string("[ ") + flag + " \"$1\" ]", "_", path});
}
static std::vector<std::string> split_lines(const std::string & text, bool strip_dot_slash) {
std::vector<std::string> result;
std::istringstream iss(text);
std::string line;
while (std::getline(iss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.empty()) continue;
if (strip_dot_slash && line.rfind("./", 0) == 0) line = line.substr(2);
std::replace(line.begin(), line.end(), '\\', '/');
result.push_back(line);
}
return result;
}
// one `find` pass in the isolate. junk directories stay selectable but are never descended into,
// and -mindepth/-maxdepth keep a busybox image working as well as a GNU one
std::vector<std::string> find_entries(const std::string & abs_base, int max_depth, bool dirs, bool & truncated) const {
std::string prune_expr;
for (const char * n : SERVER_TOOL_JUNK_DIR_NAMES) {
if (!prune_expr.empty()) prune_expr += " -o ";
prune_expr += std::string("-name ") + n;
}
std::string cmd = "cd \"$1\" && find . -mindepth 1";
if (max_depth > 0) {
cmd += " -maxdepth " + std::to_string(max_depth);
}
cmd += " \\( " + prune_expr + " \\) -prune";
cmd += dirs ? " -print -o -type d -print" : " -o -type f -print";
auto res = exec({"sh", "-c", cmd, "_", abs_base}, SERVER_TOOL_GIT_LS_FILES_MAX_OUTPUT, true);
truncated = truncated || res.timed_out;
return split_lines(res.output, /*strip_dot_slash=*/true);
}
};
// an already-running docker container, driven through `docker exec` and `docker cp`
class tools_io_docker : public tools_io_isolate {
public:
tools_io_docker(std::string container_id, std::string cwd = "")
: tools_io_isolate(std::move(cwd)), container_id(std::move(container_id)) {}
protected:
std::vector<std::string> build_argv(const std::vector<std::string> & inner, bool needs_stdin) const override {
std::vector<std::string> argv = {"docker", "exec"};
if (needs_stdin) {
argv.push_back("-i");
}
argv.push_back(container_id);
argv.insert(argv.end(), inner.begin(), inner.end());
return argv;
}
bool upload(const std::string & host_path, const std::string & isolate_path) const override {
auto res = run_subprocess(
{"docker", "cp", host_path, container_id + ":" + isolate_path},
4096, SERVER_TOOL_ISOLATE_EXEC_TIMEOUT, nullptr, true);
return res.exit_code == 0 && !res.timed_out;
}
private:
std::string container_id;
};
// runtime spec used by --tools-runtime and the x-tool-runtime header
// this is the only scheme for now, ssh: and podman: can be added next to it
static const std::string SERVER_TOOL_RUNTIME_DOCKER_CONTAINER = "docker-container:";
// an empty runtime runs the tools on the host
static std::unique_ptr<tools_io> make_tools_io(const json & params) {
std::string cwd = json_value(params, "cwd", std::string());
return std::make_unique<tools_io_basic>(cwd);
std::string cwd = json_value(params, "cwd", std::string());
std::string runtime = json_value(params, "runtime", std::string());
if (runtime.empty()) {
return std::make_unique<tools_io_basic>(cwd);
}
if (runtime.rfind(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER, 0) == 0) {
return std::make_unique<tools_io_docker>(runtime.substr(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER.size()), cwd);
}
// do not fall back to the host, the caller asked for an isolate
throw std::runtime_error("unknown tool runtime: " + runtime);
}
// no '/' in pattern -> match basename at any depth; else match full relative path
@@ -476,6 +764,7 @@ struct server_tool_read_file : server_tool {
server_tool_read_file() {
name = "read_file";
display_name = "Read file";
uses_cwd = true;
permission_write = false;
}
@@ -564,6 +853,7 @@ struct server_tool_file_glob_search : server_tool {
server_tool_file_glob_search() {
name = "file_glob_search";
display_name = "File search";
uses_cwd = true;
permission_write = false;
}
@@ -678,6 +968,7 @@ struct server_tool_grep_search : server_tool {
server_tool_grep_search() {
name = "grep_search";
display_name = "Grep search";
uses_cwd = true;
permission_write = false;
}
@@ -830,6 +1121,7 @@ struct server_tool_exec_shell_command : server_tool {
server_tool_exec_shell_command() {
name = "exec_shell_command";
display_name = "Execute shell command";
uses_cwd = true;
permission_write = true;
support_stream = true;
}
@@ -861,8 +1153,11 @@ struct server_tool_exec_shell_command : server_tool {
timeout = std::min(timeout, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_TIMEOUT);
max_output = std::min(max_output, SERVER_TOOL_EXEC_SHELL_COMMAND_MAX_OUTPUT_SIZE);
// an isolate is always POSIX regardless of host OS, so it always gets `sh -c`
#ifdef _WIN32
std::vector<std::string> args = {"cmd", "/c", command};
std::vector<std::string> args = !json_value(params, "runtime", std::string()).empty()
? std::vector<std::string>{"sh", "-c", command}
: std::vector<std::string>{"cmd", "/c", command};
#else
std::vector<std::string> args = {"sh", "-c", command};
#endif
@@ -905,6 +1200,7 @@ struct server_tool_write_file : server_tool {
server_tool_write_file() {
name = "write_file";
display_name = "Write file";
uses_cwd = true;
permission_write = true;
}
@@ -947,6 +1243,7 @@ struct server_tool_edit_file : server_tool {
server_tool_edit_file() {
name = "edit_file";
display_name = "Edit file";
uses_cwd = true;
permission_write = true;
}
@@ -1335,6 +1632,7 @@ struct server_tool_get_info : server_tool {
server_tool_get_info() {
name = "get_info";
display_name = "Get Runtime Info";
uses_cwd = true;
permission_write = false;
}
@@ -1355,11 +1653,16 @@ struct server_tool_get_info : server_tool {
json invoke(json params, server_tool::stream *) const override {
auto io = make_tools_io(params);
// inside an isolate, we always use the linux command
#ifdef _WIN32
auto res = io->run({"cmd", "/c", "ver"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
std::vector<std::string> args = !json_value(params, "runtime", std::string()).empty()
? std::vector<std::string>{"uname", "-a"}
: std::vector<std::string>{"cmd", "/c", "ver"};
#else
auto res = io->run({"uname", "-a"}, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
std::vector<std::string> args = {"uname", "-a"};
#endif
auto res = io->run(args, SERVER_TOOL_GET_INFO_MAX_OUTPUT, SERVER_TOOL_GET_INFO_TIMEOUT);
// "ver" prints a blank line before the version, so the output is stripped on both ends;
// a failed spawn or a timeout leaves a diagnostic in res.output, which is not an OS name
std::string os_info = res.exit_code == 0 && !res.timed_out ? string_strip(res.output) : "unknown";
@@ -1461,6 +1764,103 @@ struct server_mcp_tool : server_tool {
}
};
// owns the docker container used as the sandboxed runtime for tool invocations, as configured by
// --tools-runtime. "spawned" mode starts and stops the container itself; "existing" mode just reuses
// a container id the user already has running and never stops it.
struct server_tools_docker_runtime {
server_tools_docker_runtime(const server_tools_docker_runtime &) = delete;
explicit server_tools_docker_runtime(const std::string & spec) {
static const std::string docker_prefix = "docker:";
if (spec.rfind(docker_prefix, 0) == 0) {
spawned = true;
image = spec.substr(docker_prefix.size());
if (image.empty()) {
throw std::runtime_error("--tools-runtime docker:<image> requires an image name");
}
spawn();
} else if (spec.rfind(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER, 0) == 0) {
spawned = false;
container_id = spec.substr(SERVER_TOOL_RUNTIME_DOCKER_CONTAINER.size());
if (container_id.empty()) {
throw std::runtime_error("--tools-runtime docker-container:<id> requires a container id");
}
} else {
throw std::runtime_error("unknown --tools-runtime option: " + spec);
}
}
~server_tools_docker_runtime() {
if (spawned && !container_id.empty()) {
// closing stdin signals the container's shell (its pid 1) to exit; --rm then removes it
proc.close_stdin();
proc.join();
}
}
// container id to use for the next tool call; respawns a spawned container that died on its own,
// or throws if an externally-managed one is no longer reachable
std::string get_container_id() {
std::lock_guard<std::mutex> lock(mutex);
if (!spawned) {
if (!is_running(container_id)) {
throw std::runtime_error(string_format(
"docker container \"%s\" is no longer running, restart it to keep using tools",
container_id.c_str()));
}
return container_id;
}
if (!proc.alive()) {
SRV_WRN("docker tools runtime container \"%s\" died, respawning\n", container_id.c_str());
spawn();
}
return container_id;
}
private:
bool spawned = false;
std::string image; // spawned mode only
std::string container_id;
common_subproc proc; // spawned mode only: `docker run` client that keeps the container alive
std::mutex mutex;
// spawns "docker run --rm -i <image> sh" and keeps its stdin open; the shell blocks reading stdin,
// so the container stays alive until we close it (see destructor) or it is killed from the outside
void spawn() {
std::error_code ec;
fs::path cidfile = fs::temp_directory_path(ec) / string_format(
"llama-tools-runtime-cid-%zu.tmp", std::hash<std::thread::id>{}(std::this_thread::get_id()));
fs::remove(cidfile, ec);
std::vector<std::string> args = {"docker", "run", "--rm", "-i", "--cidfile", cidfile.string(), image, "sh"};
int options = subprocess_option_no_window
| subprocess_option_inherit_environment
| subprocess_option_search_user_path;
if (!proc.create(args, options)) {
throw std::runtime_error("failed to spawn docker container for tools runtime (image: " + image + ")");
}
std::string cid;
for (int i = 0; i < 100 && cid.empty(); i++) {
std::ifstream f(cidfile);
if (f) std::getline(f, cid);
if (cid.empty()) std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
fs::remove(cidfile, ec);
if (cid.empty()) {
proc.terminate();
throw std::runtime_error("timed out waiting for docker container to start (image: " + image + ")");
}
container_id = cid;
}
static bool is_running(const std::string & id) {
auto res = run_subprocess({"docker", "inspect", "-f", "{{.State.Running}}", id}, 16, 5, nullptr, true);
return res.exit_code == 0 && !res.timed_out && res.output.rfind("true", 0) == 0;
}
};
static server_tool & find_tool(std::vector<std::unique_ptr<server_tool>> & tools, const std::string & name, bool require_stream) {
for (auto & t : tools) {
if (t->name == name) {
@@ -1506,8 +1906,16 @@ static std::string get_header(const std::map<std::string, std::string> & headers
return default_value;
}
server_tools::server_tools() = default;
server_tools::~server_tools() = default;
void server_tools::setup(const std::vector<std::string> & enabled_tools,
server_mcp & mcp_mgr) {
server_mcp & mcp_mgr,
const std::string & tools_runtime) {
if (!tools_runtime.empty()) {
docker_runtime = std::make_unique<server_tools_docker_runtime>(tools_runtime);
}
if (!enabled_tools.empty()) {
if (!common_subproc::is_supported()) {
throw std::runtime_error("subprocess is not enabled on this build");
@@ -1590,11 +1998,26 @@ void server_tools::setup(const std::vector<std::string> & enabled_tools,
bool stream = body.value("stream", false);
// accept x-tool-cwd header to override of the process
if (params.contains("cwd")) {
params.erase("cwd");
}
auto cwd = get_header(req.headers, "x-tool-cwd");
if (!cwd.empty()) {
params["cwd"] = cwd;
}
// accept x-tool-runtime header to route tool I/O through an isolate, e.g. "docker-container:<id>";
// falls back to the --tools-runtime isolate, if configured
if (params.contains("runtime")) {
params.erase("runtime");
}
auto runtime = get_header(req.headers, "x-tool-runtime");
if (!runtime.empty()) {
params["runtime"] = runtime;
} else if (docker_runtime) {
params["runtime"] = SERVER_TOOL_RUNTIME_DOCKER_CONTAINER + docker_runtime->get_container_id();
}
server_tool & tool = find_tool(tools, tool_name, stream);
if (stream) {
+11 -1
View File
@@ -14,6 +14,7 @@ struct server_tool {
std::string display_name;
bool permission_write = false;
bool support_stream = false; // if true, output can be streamed
bool uses_cwd = false; // if true, the tool resolves paths and runs against the working directory
virtual ~server_tool() = default;
virtual json get_definition() const = 0;
@@ -30,6 +31,8 @@ struct server_tool {
json to_json() const;
};
struct server_tools_docker_runtime; // impl detail, defined in server-tools.cpp
struct server_tools {
std::vector<std::unique_ptr<server_tool>> tools;
@@ -37,9 +40,16 @@ struct server_tools {
server_response queue_res;
std::atomic<int> res_id{0};
// set when --tools-runtime is configured; owns the docker container used to run tools, if any
std::unique_ptr<server_tools_docker_runtime> docker_runtime;
void setup(const std::vector<std::string> & enabled_tools,
server_mcp & mcp_mgr);
server_mcp & mcp_mgr,
const std::string & tools_runtime);
server_http_context::handler_t handle_get;
server_http_context::handler_t handle_post;
server_tools();
~server_tools();
};
+4 -1
View File
@@ -338,7 +338,7 @@ int llama_server(common_params & params, int argc, char ** argv) {
if (!params.server_tools.empty() || !mcp_mgr.empty()) {
try {
tools.setup(params.server_tools, mcp_mgr);
tools.setup(params.server_tools, mcp_mgr, params.server_tools_runtime);
} catch (const std::exception & e) {
SRV_ERR("tools setup failed: %s\n", e.what());
return 1;
@@ -348,6 +348,9 @@ int llama_server(common_params & params, int argc, char ** argv) {
if (!params.server_tools.empty()) {
warn_names.push_back("built-in tools (experimental)");
}
if (!params.server_tools_runtime.empty()) {
warn_names.push_back("tools runtime (experimental)");
}
if (!mcp_mgr.empty()) {
warn_names.push_back("MCP servers (experimental)");
}
+2 -2
View File
@@ -15,7 +15,7 @@ def stop_server_after_each_test():
server.stop()
@pytest.fixture(scope="module", autouse=True)
def do_something():
@pytest.fixture(scope="session", autouse=True)
def load_server_presets():
# this will be run once per test session, before any tests
ServerPreset.load_all()
+3 -3
View File
@@ -14,10 +14,10 @@ fi
if [ $# -lt 1 ]
then
if [[ "${SLOW_TESTS:-0}" == 1 ]]; then
pytest -v -x
pytest --durations=30 -v -x
else
pytest -v -x -m "not slow"
pytest --durations=30 -v -x -m "not slow"
fi
else
pytest "$@"
pytest --durations=30 "$@"
fi
+2 -2
View File
@@ -85,7 +85,7 @@ def _wait_for_model_status(model_id: str, desired: set[str], timeout: int = 60)
last_status = _get_model_status(model_id)
if last_status in desired:
return last_status
time.sleep(1)
time.sleep(0.01)
raise AssertionError(
f"Timed out waiting for {model_id} to reach {desired}, last status: {last_status}"
)
@@ -460,7 +460,7 @@ def _wait_for_sse_event(collected: list, event_type: str, model: str, timeout: i
while time.time() < deadline:
if any(e.get("event") == event_type and e.get("model") == model for e in collected):
return True
time.sleep(0.5)
time.sleep(0.01)
return False
@@ -1,4 +1,6 @@
import os
import shutil
import subprocess
import pytest
from utils import *
@@ -146,6 +148,95 @@ def test_tools_builtin_cwd_header():
os.remove(marker_path)
def _docker_unavailable_reason() -> str | None:
"""None if docker can be used to run a container, otherwise the reason it can't."""
docker_bin = shutil.which("docker")
if docker_bin is None:
return "docker is not installed"
try:
subprocess.run([docker_bin, "info"], capture_output=True, timeout=5, check=True)
except Exception as e:
return f"docker daemon is not usable: {e}"
return None
@pytest.fixture
def docker_container():
reason = _docker_unavailable_reason()
if reason is not None:
pytest.skip(reason) # ty: ignore[too-many-positional-arguments, invalid-argument-type]
proc = subprocess.run(
["docker", "run", "-d", "--rm", "busybox", "sleep", "300"],
capture_output=True, text=True,
)
if proc.returncode != 0:
pytest.skip(f"failed to start docker container: {proc.stderr.strip()}") # ty: ignore[too-many-positional-arguments, invalid-argument-type]
container_id = proc.stdout.strip()
try:
yield container_id
finally:
subprocess.run(["docker", "rm", "-f", container_id], capture_output=True)
def test_tools_builtin_runtime_header(docker_container: str):
global server
server.start()
headers = {"x-tool-runtime": f"docker-container:{docker_container}", "x-tool-cwd": "/tmp"}
write_res = call_tool("write_file", {"path": "test.log", "content": "hello docker\n"}, headers=headers)
assert write_res["result"] == "file written successfully"
read_res = call_tool("read_file", {"path": "test.log"}, headers=headers)
assert read_res["plain_text_response"] == "hello docker\n"
exec_res = call_tool("exec_shell_command", {"command": "cat test.log"}, headers=headers)
assert "hello docker" in exec_res["plain_text_response"]
def test_tools_builtin_runtime_header_unknown_scheme():
global server
server.start()
# an unknown runtime must fail, never silently fall back to running on the host
res = server.make_request("POST", "/tools",
data={"tool": "exec_shell_command", "params": {"command": "echo hi"}},
headers={"x-tool-runtime": "ssh:example.com"})
assert res.status_code == 500, res.body
assert "unknown tool runtime" in str(res.body)
def test_tools_builtin_docker_runtime_cleans_up_spawned_container():
reason = _docker_unavailable_reason()
if reason is not None:
pytest.skip(reason) # ty: ignore[too-many-positional-arguments, invalid-argument-type]
global server
server.server_tools_runtime = "docker:busybox"
server.start()
# exec_shell_command runs inside the container spawned for --tools-runtime; docker sets
# the container's hostname to its own short id, so this also tells us which one to check
res = call_tool("exec_shell_command", {"command": "hostname"})
container_id = res["plain_text_response"].splitlines()[0].strip()
assert len(container_id) >= 8, res
running = subprocess.run(
["docker", "inspect", "-f", "{{.State.Running}}", container_id],
capture_output=True, text=True,
)
assert running.returncode == 0 and running.stdout.strip() == "true", running.stderr
server.stop()
# a clean server shutdown must stop and remove the container it spawned (it runs with --rm),
# not leave it behind as an abandoned child
leftover = subprocess.run(["docker", "inspect", container_id], capture_output=True, text=True)
assert leftover.returncode != 0, f"container {container_id} was not cleaned up after server exit"
def test_tools_builtin_edit_file_rejects_overlapping_edits():
global server
server.start()
+8 -2
View File
@@ -115,6 +115,7 @@ class ServerProcess:
backend_sampling: bool = False
gcp_compat: bool = False
server_tools: str | None = None
server_tools_runtime: str | None = None
mcp_servers_config: str | None = None
mcp_servers_json: str | None = None
cors_origins: str | None = None
@@ -270,6 +271,8 @@ class ServerProcess:
server_args.append("--ui-mcp-proxy")
if self.server_tools:
server_args.extend(["--tools", self.server_tools])
if self.server_tools_runtime:
server_args.extend(["--tools-runtime", self.server_tools_runtime])
if self.mcp_servers_config:
server_args.extend(["--mcp-servers-config", self.mcp_servers_config])
if self.mcp_servers_json:
@@ -306,6 +309,7 @@ class ServerProcess:
# wait for server to start
start_time = time.time()
last_print_time = start_time
while time.time() - start_time < timeout_seconds:
try:
response = self.make_request("GET", "/health", headers={
@@ -320,8 +324,10 @@ class ServerProcess:
if self.process.poll() is not None:
raise RuntimeError(f"Server process died with return code {self.process.returncode}")
print(f"Waiting for server to start...")
time.sleep(0.5)
if time.time() - last_print_time >= 1.0:
print(f"Waiting for server to start...")
last_print_time = time.time()
time.sleep(0.01)
raise TimeoutError(f"Server did not start within {timeout_seconds} seconds")
def stop(self) -> None:
+5 -2
View File
@@ -179,17 +179,20 @@ int main(int argc, char ** argv) {
const char * data = nullptr;
size_t data_len = 0;
int64_t n_samples = 0;
const int64_t t_wav_start_us = ggml_time_us();
if (gen.get_output(&sample_rate, &data, &data_len, &n_samples) != 0) {
LOG_ERR("get_output failed\n");
return 1;
}
const double t_wav_s = (ggml_time_us() - t_wav_start_us) / 1e6;
LOG_INF("generated %d frames, %zu bytes of WAV audio (%d Hz)\n", n_frames, data_len, sample_rate);
const double t_prompt_s = (t_gen_start_us - t_prompt_start_us) / 1e6;
const double t_total_s = t_prompt_s + t_gen_s;
const double t_total_s = t_prompt_s + t_gen_s + t_wav_s;
const double audio_s = sample_rate > 0 ? (double) n_samples / sample_rate : 0.0;
LOG_INF("timings: prompt eval %.2fs + generation %.2fs = total %.2fs\n", t_prompt_s, t_gen_s, t_total_s);
LOG_INF("timings: prompt eval %.2fs + generation %.2fs + vocoder %.2fs = total %.2fs\n",
t_prompt_s, t_gen_s, t_wav_s, t_total_s);
LOG_INF(" output audio = %.2fs (audio time = %.2fx process time)\n", audio_s, t_total_s > 0 ? audio_s / t_total_s : 0.0);
FILE * f = fopen(params.out_file.c_str(), "wb");
if (!f) {
+2 -4
View File
@@ -143,10 +143,8 @@ declare global {
idxThemeStyle?: number;
idxCodeBlock?: number;
// File System Access API - missing from older DOM lib versions.
// Used by ChatFormWorkingDirectory's native folder picker. Feature availability
// is gated at runtime via `typeof window.showDirectoryPicker === 'function'`.
showDirectoryPicker: (options?: {
// File System Access API - not in the DOM lib and unavailable in some browsers
showDirectoryPicker?: (options?: {
id?: string;
mode?: 'read' | 'readwrite';
startIn?: FileSystemHandle | string;
@@ -2,6 +2,7 @@
import {
ChatAttachmentsList,
ChatFormActions,
ChatFormContenteditable,
ChatFormFileInputInvisible,
ChatFormMcpResourcesList,
ChatFormPickers,
@@ -14,8 +15,7 @@
INPUT_CLASSES,
SETTING_CONFIG_DEFAULT,
INITIAL_FILE_SIZE,
PROMPT_CONTENT_SEPARATOR,
PROMPT_TRIGGER_PREFIX
PROMPT_CONTENT_SEPARATOR
} from '$lib/constants';
import {
ContentPartType,
@@ -47,14 +47,16 @@
} from '$lib/types';
import {
buildMentionInsertion,
containsCodeSpan,
containsFileMentionLink,
findCommandToken,
findMentionToken,
isIMEComposing,
mentionLinkEndingAt,
isOffsetInCodeBlock,
parseClipboardContent,
takeMentionDismissSnapshot,
type MentionDismissSnapshot,
uuid
} from '$lib/utils';
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
import {
AudioRecorder,
convertToWav,
@@ -111,36 +113,67 @@
}: Props = $props();
// Component References
// Shared handle of the two input renderers (textarea + contenteditable).
type ChatInputHandle = {
focus(): void;
resetHeight(): void;
getElement(): HTMLElement | undefined;
getCaretOffset(): number;
setCaretOffset(offset: number): void;
};
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
$state(undefined);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
let inputRef: ChatInputHandle | undefined = $state(undefined);
// Render-mode gate: the plain textarea by default, the contenteditable
// while the buffer carries a `file://` mention link or a complete code
// span (badges and code chips need a DOM the textarea cannot provide).
// Demotes back once neither remains.
let useContenteditable = $state(false);
// Audio Recording State
let isRecording = $state(false);
let recordingSupported = $state(false);
// Invisible anchor at the form's top edge so the mention popover floats above the box.
// Invisible anchor at the form's top edge so the mention/WD popovers
// float above the box.
let mentionAnchor: HTMLDivElement | null = $state(null);
// Picker State
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $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());
async function handleWorkingDirectoryChange(value: string | null) {
await conversationsStore.setCwd(value);
const pickers = useChatFormPickers({
getValue: () => value,
setValue: (v) => {
value = v;
onValueChange?.(v);
},
getCaretOffset: () => inputRef?.getCaretOffset(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
focusInput: refocusInput,
getShowModelSelector: () => showModelSelector,
hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()),
hasCwdTools: () => toolsStore.hasEnabledCwdTools,
getCwd: () => cwd,
getServerHome: () => toolsStore.serverHome ?? null,
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
getPickersRef: () => pickersRef
});
async function handleWorkingDirectoryChange(newDir: string | null) {
// Committing a directory consumes the `/cwd` token; the chip's
// clear-X path has no token to consume.
const token = findCommandToken(value);
if (token && token.name === 'cwd') {
value = '';
onValueChange?.('');
}
await conversationsStore.setCwd(newDir);
if (conversationsStore.activeConversation) {
await chatStore.recordCwdChange(value?.trim() || null);
await chatStore.recordCwdChange(newDir?.trim() || null);
}
}
@@ -187,23 +220,45 @@
);
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
// Caret offset restored after a renderer swap. Callers that mutate
// `value` themselves (e.g. the mention picker) pin the target offset
// BEFORE the assignment; otherwise the swap effect snapshots the
// current caret.
let pendingCaretOffset = 0;
let caretOffsetPinned = false;
function queueCaretRestore() {
queueMicrotask(() => {
inputRef?.focus();
inputRef?.setCaretOffset(pendingCaretOffset);
caretOffsetPinned = false;
});
}
$effect(() => {
const wantContenteditable =
containsFileMentionLink(value ?? '') || containsCodeSpan(value ?? '');
if (useContenteditable === wantContenteditable) return;
if (!caretOffsetPinned) {
pendingCaretOffset = inputRef?.getCaretOffset() ?? (value ?? '').length;
}
useContenteditable = wantContenteditable;
queueCaretRestore();
});
onMount(() => {
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();
});
// Defer so the closing popover's focus scope tears down first - bits-ui
// yanks a synchronous focus() back into the still-mounted popover.
function refocusInput() {
queueMicrotask(() => textareaRef?.focus());
}
export function focus() {
textareaRef?.focus();
inputRef?.focus();
}
export function resetTextareaHeight() {
textareaRef?.resetHeight();
inputRef?.resetHeight();
}
export function openModelSelector() {
@@ -237,79 +292,10 @@
}
}
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);
} else {
isPromptPickerOpen = false;
promptSearchQuery = '';
}
}
function handleKeydown(event: KeyboardEvent) {
if (pickersRef?.handleKeydown(event)) {
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 && isMentionPickerOpen) {
isMentionPickerOpen = false;
mentionQuery = '';
// Pickers consume navigation/escape keys first; when consumed, skip
// the enter-to-submit logic below.
if (pickers.handleKeydown(event)) {
return;
}
@@ -317,6 +303,15 @@
const isModifier = event.ctrlKey || event.metaKey;
const sendOnEnter = currentConfig.sendOnEnter !== false;
// Caret inside a fenced code block (closed, or still open
// while being typed): Enter adds a line, never submits. The
// contenteditable consumes this case locally; this gate
// covers the plain textarea, where skipping submit lets the
// native newline through.
if (!isModifier && isOffsetInCodeBlock(value ?? '', inputRef?.getCaretOffset() ?? 0)) {
return;
}
if (sendOnEnter || isModifier) {
event.preventDefault();
@@ -386,7 +381,7 @@
}
setTimeout(() => {
textareaRef?.focus();
inputRef?.focus();
}, 10);
return;
@@ -413,13 +408,7 @@
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) {
// Only clear the value if the prompt was triggered by typing '/'
if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
isPromptPickerOpen = false;
promptSearchQuery = '';
pickers.closePromptPicker();
const promptName = promptInfo.title || promptInfo.name;
const placeholder: ChatUploadedFile = {
@@ -438,7 +427,7 @@
uploadedFiles = [...uploadedFiles, placeholder];
onUploadedFilesChange?.(uploadedFiles);
textareaRef?.focus();
inputRef?.focus();
}
function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) {
@@ -480,39 +469,36 @@
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptPickerClose() {
isPromptPickerOpen = false;
promptSearchQuery = '';
textareaRef?.focus();
// Deferred so the closing popover's focus scope tears down first -
// bits-ui yanks a synchronous focus() back into the still-mounted popover.
function refocusInput() {
queueMicrotask(() => inputRef?.focus());
}
function handleMentionPickerClose() {
if (isMentionPickerOpen) {
const cursor = textareaRef?.getCaretOffset() ?? value.length;
mentionDismissedSnapshot = takeMentionDismissSnapshot(value, cursor);
}
isMentionPickerOpen = false;
mentionQuery = '';
refocusInput();
}
// Splice the `[name](file:///<abs path>)` link in place of the `@<query>`
// token, restoring the caret after the bindable value settles.
// Splice the mention link in place of the `@<query>` token. Uses the
// live cursor, not a stale snapshot - the token may have been edited.
function handleMentionSelect(entry: FileMentionEntry) {
const cursor = textareaRef?.getCaretOffset() ?? value.length;
const cursor = inputRef?.getCaretOffset() ?? value.length;
const token = findMentionToken(value, cursor);
if (!token) return;
const built = buildMentionInsertion(entry, value, token);
if (!built) return;
// Pin the post-insertion caret BEFORE the swap effect runs;
// otherwise the effect clobbers it with the textarea's selection
// at promotion time (browser-dependent: usually reset to 0).
pendingCaretOffset = built.caretOffset;
caretOffsetPinned = true;
value = built.newValue;
onValueChange?.(built.newValue);
queueMicrotask(() => {
textareaRef?.focus();
textareaRef?.setCaretOffset(built.caretOffset);
});
// Already in contenteditable mode: no renderer flip, so the swap
// effect's caret restore never runs.
if (useContenteditable) {
queueCaretRestore();
}
}
async function handleMicClick() {
@@ -557,15 +543,20 @@
>
<ChatFormPickers
bind:this={pickersRef}
{isPromptPickerOpen}
{promptSearchQuery}
{isMentionPickerOpen}
{mentionQuery}
isCommandPickerOpen={pickers.isCommandPickerOpen}
commandQuery={pickers.commandQuery}
commands={pickers.availableCommands}
onCommandPickerClose={pickers.handleCommandPickerClose}
onCommandSelect={pickers.handleCommandSelect}
isPromptPickerOpen={pickers.isPromptPickerOpen}
promptSearchQuery={pickers.promptSearchQuery}
isMentionPickerOpen={pickers.isMentionPickerOpen}
mentionQuery={pickers.mentionQuery}
{mentionAnchor}
scopePath={cwd}
onPromptPickerClose={handlePromptPickerClose}
onMentionPickerClose={handleMentionPickerClose}
onMentionOpened={() => textareaRef?.focus()}
scopePath={pickers.mentionScopePath}
onPromptPickerClose={pickers.handlePromptPickerClose}
onMentionPickerClose={pickers.handleMentionPickerClose}
onMentionOpened={() => inputRef?.focus()}
onMentionSelect={handleMentionSelect}
onPromptLoadStart={handlePromptLoadStart}
onPromptLoadComplete={handlePromptLoadComplete}
@@ -596,20 +587,36 @@
<div
class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
onpaste={handlePaste}
>
<ChatFormTextarea
class="px-5 py-1.5 md:pt-0"
bind:this={textareaRef}
bind:value
onKeydown={handleKeydown}
onInput={() => {
handleInput();
onValueChange?.(value);
}}
{disabled}
{placeholder}
/>
{#if useContenteditable}
<ChatFormContenteditable
class="px-5 py-1.5 md:pt-0 mb-0.5"
bind:this={inputRef}
bind:value
onKeydown={handleKeydown}
onInput={() => {
pickers.handleInput();
onValueChange?.(value);
}}
onPaste={handlePaste}
{disabled}
{placeholder}
/>
{:else}
<ChatFormTextarea
class="px-5 py-1.5 md:pt-0"
bind:this={inputRef}
bind:value
onKeydown={handleKeydown}
onInput={() => {
pickers.handleInput();
onValueChange?.(value);
}}
onPaste={handlePaste}
{disabled}
{placeholder}
/>
{/if}
{#if mcpHasResourceAttachments()}
<ChatFormMcpResourcesList
@@ -636,7 +643,7 @@
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
/>
</div>
@@ -644,11 +651,15 @@
<ContextGaugePopup />
{#if toolsStore.builtinTools.length > 0}
{#if toolsStore.hasEnabledCwdTools}
<ChatFormWorkingDirectory
directory={cwd}
isOpen={pickers.isWorkingDirectoryPickerOpen}
bind:query={pickers.workingDirectoryQuery}
customAnchor={mentionAnchor}
onChange={handleWorkingDirectoryChange}
onClose={refocusInput}
onClose={pickers.handleWorkingDirectoryClose}
onOpen={pickers.handleWorkingDirectoryOpen}
{disabled}
/>
{/if}
@@ -0,0 +1,788 @@
<script lang="ts">
import { onDestroy, onMount, untrack } from 'svelte';
import { mode } from 'mode-watcher';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import { isMobile } from '$lib/stores/viewport.svelte';
import { ColorMode } from '$lib/enums';
import { TRIM_LEADING_PADDING_REGEX, TRIM_TRAILING_PADDING_REGEX } from '$lib/constants';
import {
badgeAwareWordJump,
buildFragment,
domMatchesTokens,
highlightCode,
isIMEComposing,
isOffsetInCodeBlock,
leadingBadgeEdgeOffset,
rangeToTextOffset,
serializeContent,
SourceHistory,
stripBlockBoundaryLineBreaks,
syncCodeBlockHatches,
tokenizeContent,
textOffsetToRange
} from '$lib/utils';
import type { ContentToken, SourceHistoryEntry } from '$lib/utils';
interface Props {
class?: string;
disabled?: boolean;
onInput?: () => void;
onKeydown?: (event: KeyboardEvent) => void;
onPaste?: (event: ClipboardEvent) => void;
placeholder?: string;
value?: string;
}
let {
class: className = '',
disabled = false,
onInput,
onKeydown,
onPaste,
placeholder = 'Ask anything...',
value = $bindable('')
}: Props = $props();
let rootElement: HTMLDivElement | undefined = $state();
let lastEmittedValue = '';
let isComposing = $state(false);
// Undo/redo in source space: the imperative token rebuilds destroy the
// browser's native undo stack.
const history = new SourceHistory();
// Browsers disagree on what an empty contenteditable contains (`<br>`,
// `<div><br></div>`, or nothing), so emptiness is decided by the
// serialized source, not the DOM shape.
function syncEmptyState(serialized?: string) {
if (!rootElement) return;
const source = serialized ?? serializeContent(rootElement);
rootElement.dataset.empty = source.length === 0 ? 'true' : 'false';
}
function renderTokens(tokens: ContentToken[]) {
if (!rootElement) return;
const caret = rangeToTextOffset(rootElement, safeRange());
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
rootElement.replaceChildren(buildFragment(tokens));
syncCodeBlockHatches(rootElement);
highlightCodeBlocks(rootElement);
restoreCaret(caret);
resizeHeight();
syncEmptyState();
}
// Last highlighted source segment per block element - typing inside
// a block re-highlights only when the segment actually changed.
const highlightedSegments = new WeakMap<HTMLElement, string>();
const CODE_BLOCK_OPEN_RE = /^```([^\n`]*)\n/;
/**
* Apply syntax highlighting to a code block element's CONTENT. The
* fence lines stay plain text, and the blank padding that
* `highlightCode` trims is re-added as plain text, so the element's
* textContent stays byte-exact with the source segment. Replaces
* the element's children - callers restore the caret afterwards.
* Returns false when nothing changed.
*/
function highlightCodeBlockElement(el: HTMLElement): boolean {
const segment = el.textContent ?? '';
if (highlightedSegments.get(el) === segment) return false;
const open = CODE_BLOCK_OPEN_RE.exec(segment);
if (!open) return false;
const prefix = open[0];
const language = open[1].trim().split(/\s+/)[0] ?? '';
const content = segment.slice(prefix.length, -3);
const leading = content.match(TRIM_LEADING_PADDING_REGEX)?.[0] ?? '';
const trailing = content.match(TRIM_TRAILING_PADDING_REGEX)?.[0] ?? '';
const core = content.slice(leading.length, content.length - trailing.length);
// autoDetect off: re-guessing the language on every keystroke
// costs ~38ms a call and flickers while typing
const html = core ? highlightCode(core, language || 'text', false) : '';
const tpl = document.createElement('template');
tpl.innerHTML = html;
el.replaceChildren(
document.createTextNode(prefix + leading),
tpl.content.cloneNode(true),
document.createTextNode(trailing + '```')
);
highlightedSegments.set(el, segment);
return true;
}
function highlightCodeBlocks(root: HTMLElement) {
for (const el of root.querySelectorAll<HTMLElement>('code[data-code-token="block"]')) {
highlightCodeBlockElement(el);
}
}
/**
* Re-highlight the code block the caret sits in after an edit.
* Skipped when the block's segment is unchanged since its last
* highlight, so edits outside blocks cost nothing.
*/
function rehighlightCaretCodeBlock() {
if (!rootElement) return;
const range = safeRange();
if (!range) return;
let node: Node | null = range.startContainer;
if (node === rootElement) {
node = rootElement.childNodes[range.startOffset - 1] ?? null;
}
while (node && node !== rootElement) {
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
const caret = rangeToTextOffset(rootElement, range);
if (highlightCodeBlockElement(node)) {
restoreCaret(caret);
}
return;
}
node = node.parentNode;
}
}
/**
* Is the caret inside a fenced code block region? Source-level
* (not DOM-level) so the still-OPEN fence counts too: while the
* user is typing a block, no closing ``` exists yet and the
* buffer is plain text with no block element to find. Root-level
* caret positions right at a closed block's edge (escape
* hatches, element boundaries restored by `textOffsetToRange`)
* resolve past the closing fence, so they count as OUTSIDE.
*/
function caretInCodeBlock(): boolean {
if (!rootElement) return false;
return isOffsetInCodeBlock(
serializeContent(rootElement),
rangeToTextOffset(rootElement, safeRange())
);
}
/**
* hljs theme for the highlighted code blocks. Mirrors
* SyntaxHighlightedCode.svelte: one shared style element
* (deduped via the data attribute) swapped on mode change.
*/
function loadHighlightTheme(isDark: boolean) {
document.querySelectorAll('style[data-highlight-theme-preview]').forEach((s) => s.remove());
const style = document.createElement('style');
style.setAttribute('data-highlight-theme-preview', 'true');
style.textContent = isDark ? githubDarkCss : githubLightCss;
document.head.appendChild(style);
}
$effect(() => {
loadHighlightTheme(mode.current === ColorMode.DARK);
});
function safeRange(): Range | null {
if (!rootElement) return null;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
if (!rootElement.contains(range.startContainer) || !rootElement.contains(range.endContainer)) {
return null;
}
return range;
}
function restoreCaret(offset: number, extend = false) {
if (!rootElement) return;
const target = textOffsetToRange(rootElement, offset);
const selection = window.getSelection();
if (!selection) return;
if (extend && selection.anchorNode) {
selection.setBaseAndExtent(
selection.anchorNode,
selection.anchorOffset,
target.startContainer,
target.startOffset
);
return;
}
selection.removeAllRanges();
selection.addRange(target);
}
function resizeHeight() {
if (!rootElement) return;
rootElement.style.height = 'auto';
rootElement.style.height = `${rootElement.scrollHeight}px`;
}
function recordHistory(newGroup: boolean) {
if (!rootElement) return;
history.push(
{ value: lastEmittedValue, caret: rangeToTextOffset(rootElement, safeRange()) },
Date.now(),
newGroup
);
}
/**
* Re-emit the current markdown source value to the parent, then
* reconcile the DOM against the token stream: when a code span
* was just completed or broken, the token boundaries no longer
* match the element structure and the DOM is rebuilt (caret
* preserved through the source-offset mapping).
*/
function processInput(inputType?: string) {
if (isComposing || !rootElement) return;
syncEmptyState();
resizeHeight();
// Shift+Enter right after a code block leaves an all-newline
// text node (the fence's separator line plus Chromium's
// artificial end-of-buffer line break). Strip both so the caret
// lands on the line directly below the block.
if (inputType === 'insertLineBreak' || inputType === 'insertParagraph') {
const caret = rangeToTextOffset(rootElement, safeRange());
if (stripBlockBoundaryLineBreaks(rootElement)) {
restoreCaret(caret);
} else {
const source = serializeContent(rootElement);
let end = caret;
// the caret must end up after the inserted \n; some browsers
// leave it before (stuck at the end of the old line). A
// preceding \n means it already sits past the break
// (Chromium's artificial trailing newline) - leave it.
if (source[end] === '\n' && source[end - 1] !== '\n') {
end += 1;
restoreCaret(end);
}
// a line break at the buffer end renders only with a second,
// artificial trailing \n: a lone trailing \n is collapsed, so
// the new line is invisible and the next typed character
// consumes it. Append it when missing - unless the trailing
// \n doubles as a block's separator line (source ends with
// \n\n) or sits inside a block element.
let last = rootElement.lastChild;
while (last && last.nodeName === 'BR') last = last.previousSibling;
if (
end === source.length &&
source.endsWith('\n') &&
source[source.length - 2] !== '\n' &&
last?.nodeType === Node.TEXT_NODE
) {
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
rootElement.appendChild(document.createTextNode('\n'));
restoreCaret(source.length);
resizeHeight();
}
}
}
syncCodeBlockHatches(rootElement);
const serialized = serializeContent(rootElement);
syncEmptyState(serialized);
if (serialized === lastEmittedValue) return;
// Plain typing/deletes coalesce per time window; structural edits
// (paste, newline, cut, autocorrect) start a new undo group.
recordHistory(inputType !== 'insertText' && !inputType?.startsWith('deleteContent'));
lastEmittedValue = serialized;
value = serialized;
// Rebuild when token boundaries shifted (a code span was just
// completed or broken) - the browser-owned text nodes cannot
// restyle themselves across element boundaries.
const tokens = tokenizeContent(serialized);
if (!domMatchesTokens(rootElement, tokens)) {
renderTokens(tokens);
// The rebuild can re-shape the DOM in a way that changes the
// serialization (e.g. Chromium merged trailing text into the
// block element and the rebuild splits it back out, which
// synthesizes the separator newline) - keep value in sync.
const reserialized = serializeContent(rootElement);
if (reserialized !== serialized) {
lastEmittedValue = reserialized;
value = reserialized;
}
} else {
rehighlightCaretCodeBlock();
}
onInput?.();
}
function handleInput(event: Event) {
processInput((event as InputEvent).inputType);
}
function handleCompositionStart() {
isComposing = true;
}
function handleCompositionEnd() {
isComposing = false;
processInput();
}
/**
* Insert a line break at the caret MANUALLY. Native Shift+Enter at
* the buffer end varies across browsers (a lone trailing \n that the
* renderer collapses, or a <br> that the hatch sync strips), which
* can leave the caret stuck on the old line; splitting the text node
* ourselves keeps the DOM shape - and the caret - deterministic.
* `processInput` then appends the artificial trailing \n when the
* break lands at the buffer end.
*/
function insertLineBreak() {
if (!rootElement) return;
const range = safeRange();
if (!range) return;
if (!range.collapsed) {
range.deleteContents();
}
const container = range.startContainer;
const offset = range.startOffset;
const nl = document.createTextNode('\n');
// a break at the very end of a code block exits the block (the
// new line belongs below it, not inside)
let exitBlock: HTMLElement | null = null;
if (container.nodeType === Node.TEXT_NODE) {
let node: Node | null = container.parentNode;
while (node && node !== rootElement) {
if (node instanceof HTMLElement && node.dataset.codeToken === 'block') {
const tail = document.createRange();
tail.setStart(container, offset);
tail.setEnd(node, node.childNodes.length);
if (tail.toString().length === 0) exitBlock = node;
break;
}
node = node.parentNode;
}
}
if (exitBlock) {
exitBlock.after(nl);
} else if (container.nodeType === Node.TEXT_NODE) {
const text = container as Text;
if (offset === 0) {
text.before(nl);
} else if (offset === text.length) {
text.after(nl);
} else {
text.splitText(offset).before(nl);
}
} else {
container.insertBefore(nl, container.childNodes[offset] ?? null);
}
const selection = window.getSelection();
const after = document.createRange();
after.setStartAfter(nl);
after.collapse(true);
selection?.removeAllRanges();
selection?.addRange(after);
processInput('insertLineBreak');
}
/**
* Arrow escape to the line BEFORE a leading code block. Native
* caret movement has no position above a buffer-starting block,
* so a transient `<br>` hatch is created on demand: it gives the
* caret a visible line, is consumed by the first character typed
* on it, and is removed again when the caret leaves (see
* handleSelectionChange). Returns true when the caret was moved.
*/
function moveCaretBeforeLeadingCodeBlock(key: string, extend: boolean): boolean {
if (!rootElement) return false;
// a hatch already exists - native movement handles it
if (rootElement.firstChild?.nodeName === 'BR') return false;
const first = rootElement.firstChild;
if (!(first instanceof HTMLElement) || first.dataset.codeToken !== 'block') return false;
const range = safeRange();
if (!range || !range.collapsed) return false;
// the caret must sit inside the block: on its very first
// character for ArrowLeft, anywhere on its first line for
// ArrowUp
if (!first.contains(range.startContainer)) return false;
const caret = rangeToTextOffset(rootElement, range);
if (key === 'ArrowLeft') {
if (caret !== 0) return false;
} else {
const firstLineEnd = (first.textContent ?? '').indexOf('\n');
if (firstLineEnd !== -1 && caret > firstLineEnd) return false;
}
// eslint-disable-next-line svelte/no-dom-manipulating -- the token layer is owned imperatively; Svelte renders only the contenteditable host, never its children
rootElement.prepend(document.createElement('br'));
restoreCaret(0, extend);
return true;
}
/**
* Remove the transient leading hatch once the caret leaves it.
* The hatch only exists to give the caret a line above a leading
* code block; with the caret anywhere else the empty line would
* just be visual noise. Typing on the hatch line consumes it via
* the stale-hatch removal in `syncCodeBlockHatches` instead (the
* new text node takes its place before the block).
*/
function handleSelectionChange() {
if (!rootElement) return;
const first = rootElement.firstChild;
if (first?.nodeName !== 'BR') return;
const second = first.nextSibling;
if (!(second instanceof HTMLElement) || second.dataset.codeToken !== 'block') return;
const range = safeRange();
const onHatch =
range !== null && range.startContainer === rootElement && range.startOffset === 0;
if (!onHatch) {
first.remove();
}
}
/**
* Undo/redo is replayed from source snapshots (the token rebuilds
* destroy the native undo stack). Arrow keys around badges are
* repaired locally: a badge is a non-editable island, so plain
* ArrowLeft after a leading badge has no native previous position
* and word jumps overshoot it by a word.
*
* Plain Enter inside a fenced code block (closed, or still open
* while being typed) acts as Shift+Enter and adds a line instead of
* submitting. ArrowLeft/ArrowUp at the edge of a leading code block
* create the transient before-block hatch.
*/
function handleKeydown(event: KeyboardEvent) {
const mod = event.ctrlKey || event.metaKey;
if (mod && !event.altKey && !isComposing && rootElement) {
const key = event.key.toLowerCase();
const isUndo = key === 'z' && !event.shiftKey;
const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
if (isUndo || isRedo) {
event.preventDefault();
const current = {
value: lastEmittedValue,
caret: rangeToTextOffset(rootElement, safeRange())
};
const entry = isUndo ? history.undo(current) : history.redo(current);
if (entry) applyHistoryEntry(entry);
return;
}
}
if (
event.key === 'Enter' &&
event.shiftKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!isIMEComposing(event) &&
!disabled &&
!caretInCodeBlock() &&
safeRange()
) {
// Own the break outside code blocks: native end-of-buffer
// behavior varies across browsers and can leave the caret
// stuck on the old line (see insertLineBreak).
event.preventDefault();
insertLineBreak();
return;
}
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!isIMEComposing(event) &&
caretInCodeBlock()
) {
// The native plain-Enter path must never run: it splits the
// buffer into `<div>` wrappers that `serializeContent` cannot
// see. `insertLineBreak` reproduces the Shift+Enter DOM (a `\n`
// text node) and fires `input` synchronously, so the usual
// re-tokenize/re-highlight follows.
event.preventDefault();
document.execCommand('insertLineBreak');
return;
}
if (
rootElement &&
(event.key === 'ArrowLeft' || event.key === 'ArrowUp') &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey
) {
if (moveCaretBeforeLeadingCodeBlock(event.key, event.shiftKey)) {
event.preventDefault();
return;
}
}
if (rootElement && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
const isWordJump = (event.altKey || event.ctrlKey) && !event.metaKey;
const isPlainLeft =
event.key === 'ArrowLeft' && !event.altKey && !event.ctrlKey && !event.metaKey;
if (isWordJump || isPlainLeft) {
const source = serializeContent(rootElement);
const caret = rangeToTextOffset(rootElement, safeRange());
const target = isWordJump
? badgeAwareWordJump(source, caret, event.key === 'ArrowRight' ? 'forward' : 'backward')
: leadingBadgeEdgeOffset(source, caret);
if (target !== null) {
event.preventDefault();
restoreCaret(target, event.shiftKey);
return;
}
}
}
onKeydown?.(event);
}
// lastEmittedValue is set before `value` so the sync effect treats the
// change as our own and does not re-render.
function applyHistoryEntry(entry: SourceHistoryEntry) {
if (!rootElement) return;
renderTokens(tokenizeContent(entry.value));
lastEmittedValue = entry.value;
value = entry.value;
onInput?.();
restoreCaret(entry.caret);
}
/**
* Plain-text paste. preventDefault + manual insertText keeps the
* browser from producing stray `<div>` wrappers mid-paste; insertText
* fires `input` synchronously, so `processInput` re-tokenizes the
* buffer and rebuilds when the pasted text carries badge or code
* tokens.
*/
function handlePasteEvent(event: ClipboardEvent) {
const pasted = event.clipboardData?.getData('text/plain');
if (pasted && pasted.length > 0) {
event.preventDefault();
// Snap a collapsed caret through the offset mapping first: at
// element-boundary carets (e.g. right before a badge) Chromium's
// insertText can drop the preceding text node's trailing whitespace.
const range = safeRange();
if (rootElement && range && range.collapsed) {
restoreCaret(rangeToTextOffset(rootElement, range));
}
document.execCommand('insertText', false, pasted);
}
}
// The parent's paste handler runs first and preventDefaults when it
// consumes the event (files, quoted prompts, long text).
function handlePaste(event: ClipboardEvent) {
onPaste?.(event);
if (!event.defaultPrevented) {
handlePasteEvent(event);
}
}
// The selection as markdown SOURCE (each badge contributes its full
// `[name](file://...)` link), so copy/cut carry raw markdown and
// pasting back re-renders the badges. Null for collapsed/outside
// selections - native clipboard behavior is fine there.
function selectionSourceSlice(): { text: string; range: Range } | null {
if (!rootElement) return null;
const range = safeRange();
if (!range || range.collapsed) return null;
const startRange = range.cloneRange();
startRange.collapse(true);
const source = serializeContent(rootElement);
const start = rangeToTextOffset(rootElement, startRange);
const end = rangeToTextOffset(rootElement, range);
return { text: source.slice(start, end), range };
}
function handleCopy(event: ClipboardEvent) {
const slice = selectionSourceSlice();
if (!slice) return;
event.clipboardData?.setData('text/plain', slice.text);
event.preventDefault();
}
function handleCut(event: ClipboardEvent) {
const slice = selectionSourceSlice();
if (!slice) return;
event.clipboardData?.setData('text/plain', slice.text);
event.preventDefault();
// preventDefault suppresses the native deletion, so remove the
// selection manually and re-emit.
slice.range.deleteContents();
processInput('deleteByCut');
}
onMount(() => {
// untrack: the DOM is managed manually from input events, so the
// initial render must not subscribe to the value.
renderTokens(tokenizeContent(untrack(() => value)));
lastEmittedValue = untrack(() => value ?? '');
resizeHeight();
syncEmptyState();
document.addEventListener('selectionchange', handleSelectionChange);
if (!isMobile.current) {
rootElement?.focus({ preventScroll: true });
}
});
onDestroy(() => {
document.removeEventListener('selectionchange', handleSelectionChange);
});
// External `value` updates. When incoming === lastEmittedValue the
// change came from our own input, so leave the DOM alone - the
// browser already owns the right shape.
$effect(() => {
const incoming = value ?? '';
if (incoming === lastEmittedValue) return;
recordHistory(true); // external edit (mention insert, clear, ...): own undo step
renderTokens(tokenizeContent(incoming));
lastEmittedValue = incoming;
});
export function getElement() {
return rootElement;
}
export function getCaretOffset(): number {
if (!rootElement) return 0;
return rangeToTextOffset(rootElement, safeRange());
}
// Focus first: `selection.addRange` requires it on some browsers.
export function setCaretOffset(offset: number) {
if (rootElement && rootElement !== document.activeElement) {
rootElement.focus({ preventScroll: true });
}
restoreCaret(offset);
}
export function focus() {
if (isMobile.current) return;
rootElement?.focus({ preventScroll: true });
}
export function resetHeight() {
if (rootElement) {
rootElement.style.height = '';
resizeHeight();
}
}
</script>
<div class="flex-1 {className}">
<div
bind:this={rootElement}
contenteditable={!disabled}
role="textbox"
aria-multiline="true"
aria-disabled={disabled}
aria-placeholder={placeholder}
data-placeholder={placeholder}
tabindex={disabled ? -1 : 0}
class={[
'chat-form-contenteditable text-md min-h-12 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 leading-6 outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
disabled && 'cursor-not-allowed'
]}
style="max-height: var(--max-message-height);"
oncompositionstart={handleCompositionStart}
oncompositionend={handleCompositionEnd}
oninput={handleInput}
onkeydown={handleKeydown}
onpaste={handlePaste}
oncopy={handleCopy}
oncut={handleCut}
></div>
</div>
<style>
/* pre-wrap is load-bearing: without it Chromium collapses \n in
text nodes and converts them to spaces while typing */
.chat-form-contenteditable {
white-space: pre-wrap;
}
.chat-form-contenteditable:global([data-empty='true'])::before {
content: attr(data-placeholder);
color: var(--muted-foreground);
pointer-events: none;
}
/* Inline code - mirrors markdown-content.css */
.chat-form-contenteditable :global(code[data-code-token='inline']) {
background: var(--muted);
color: var(--muted-foreground);
padding: 0.125rem 0.375rem;
border-radius: 0.375rem;
font-size: 0.875rem;
}
/* Fenced code block - mirrors .code-block-wrapper in markdown-content.css */
.chat-form-contenteditable :global(code[data-code-token='block']) {
display: block;
margin: 0.25rem 0;
padding: 0.75rem 1rem;
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
border-radius: 0.75rem;
background: var(--code-background);
color: var(--code-foreground);
font-size: 0.875rem;
line-height: 1.3;
}
</style>
@@ -0,0 +1,135 @@
<script lang="ts">
import { FolderOpen, Sparkles } from '@lucide/svelte';
import { MODEL_SELECTOR_ICON } from '$lib/constants';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { ChatFormCommandAction } from '$lib/enums';
import type { ChatFormCommand } from '$lib/types';
import {
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerPopover
} from '$lib/components/app/chat';
/**
* Slash-command picker; `query` (typed after `/`) filters the commands.
* The parent owns the "dismissed token, don't act until it changes"
* snapshot, so this picker just renders and reports selection.
*/
interface Props {
class?: string;
isOpen: boolean;
query: string;
commands: ChatFormCommand[];
onClose: () => void;
onSelect: (command: ChatFormCommand) => void;
}
let { class: className = '', isOpen, query, commands, onClose, onSelect }: Props = $props();
const commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
[ChatFormCommandAction.PROMPT]: Sparkles,
[ChatFormCommandAction.CWD]: FolderOpen,
[ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON
};
const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
const filteredCommands = $derived(
trimmedQuery
? commands.filter(
(c) =>
c.name.toLowerCase().includes(trimmedQuery) ||
c.description.toLowerCase().includes(trimmedQuery) ||
(c.keywords ?? []).some((k) => k.toLowerCase().includes(trimmedQuery))
)
: commands
);
function firstEnabledIndex(): number {
return filteredCommands.findIndex((c) => !c.disabled);
}
function stepEnabled(from: number, dir: number): number {
const n = filteredCommands.length;
if (n === 0) return -1;
for (let i = 1; i <= n; i++) {
const idx = (from + dir * i + n) % n;
if (!filteredCommands[idx].disabled) return idx;
}
return -1;
}
const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => filteredCommands.length,
step: (from, dir) => (from < 0 ? firstEnabledIndex() : stepEnabled(from, dir)),
onClose: () => onClose(),
onSelect: (index) => handleSelect(filteredCommands[index])
});
$effect(() => {
if (isOpen) {
nav.reset(firstEnabledIndex());
}
});
$effect(() => {
if (nav.hoveredIndex < 0 || nav.hoveredIndex >= filteredCommands.length) {
nav.reset(firstEnabledIndex());
return;
}
if (filteredCommands[nav.hoveredIndex].disabled) {
nav.reset(firstEnabledIndex());
}
});
function handleSelect(command: ChatFormCommand) {
if (command.disabled) return;
onSelect(command);
onClose();
}
export function handleKeydown(event: KeyboardEvent): boolean {
return nav.handleKeydown(event);
}
</script>
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open command picker"
{onClose}
onKeydown={handleKeydown}
>
<ChatFormPickerList
items={filteredCommands}
isLoading={false}
selectedIndex={nav.hoveredIndex}
showSearchInput={false}
searchQuery={query ?? ''}
emptyMessage="No matching command"
itemKey={(command) => command.name}
scrollTrigger={nav.scrollTrigger}
>
{#snippet item(command, index, isSelected)}
{@const Icon = commandIcon[command.action]}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
disabled={command.disabled}
onclick={() => handleSelect(command)}
onmouseenter={() => {
if (!command.disabled) nav.setHover(index);
}}
>
<Icon class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div class="flex min-w-0 flex-1 flex-col">
<span class="font-mono text-sm font-medium">/{command.name}</span>
<span class="min-w-0 flex-1 truncate text-left text-xs text-muted-foreground">
{command.description}
</span>
</div>
</ChatFormPickerListItem>
{/snippet}
</ChatFormPickerList>
</ChatFormPickerPopover>
@@ -2,7 +2,7 @@
import { File, Folder } from '@lucide/svelte';
import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils';
import { toolsStore } from '$lib/stores/tools.svelte';
import { BuiltInTool, FileMentionEntryType, GlobSearchType } from '$lib/enums';
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
import { isMobile } from '$lib/stores/viewport.svelte';
import { config } from '$lib/stores/settings.svelte';
import * as Popover from '$lib/components/ui/popover';
@@ -162,6 +162,17 @@
}
export function handleKeydown(event: KeyboardEvent): boolean {
// Always consume Enter while the picker is open - even with no
// result yet (skeletons) or no matches - so the chat form's
// Enter-to-submit never fires mid-search.
if (isOpen && event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (nav.hoveredIndex >= 0 && displayedItems[nav.hoveredIndex]) {
handleSelect(displayedItems[nav.hoveredIndex]);
}
return true;
}
return nav.handleKeydown(event);
}
</script>
@@ -45,6 +45,9 @@
let promptArgs = $state<Record<string, string>>({});
let selectedIndex = $state(0);
let internalSearchQuery = $state('');
// Bumped on ArrowUp/ArrowDown only, so the list scrolls on keyboard
// nav but not on hover or result changes.
let scrollTrigger = $state(0);
let promptError = $state<string | null>(null);
let selectedIndexBeforeArgumentForm = $state<number | null>(null);
@@ -295,6 +298,7 @@
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
scrollTrigger++;
}
return true;
@@ -304,6 +308,7 @@
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
scrollTrigger++;
}
return true;
@@ -400,6 +405,7 @@
searchPlaceholder="Search prompts..."
emptyMessage="No MCP prompts available"
itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
{scrollTrigger}
>
{#snippet item(prompt, index, isSelected)}
{@const server = serverSettingsMap.get(prompt.serverName)}
@@ -1,15 +1,26 @@
<script lang="ts">
import ChatFormCommandPicker from './ChatFormCommandPicker.svelte';
import ChatFormMentionPicker from './ChatFormMentionPicker.svelte';
import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
import type { FileMentionEntry, GetPromptResult, MCPPromptInfo } from '$lib/types';
import type {
ChatFormCommand,
FileMentionEntry,
GetPromptResult,
MCPPromptInfo
} from '$lib/types';
interface Props {
isCommandPickerOpen?: boolean;
commandQuery?: string;
commands?: ChatFormCommand[];
isPromptPickerOpen?: boolean;
promptSearchQuery?: string;
isMentionPickerOpen?: boolean;
mentionQuery?: string;
mentionAnchor?: HTMLElement | null;
scopePath?: string | null;
onCommandPickerClose?: () => void;
onCommandSelect?: (command: ChatFormCommand) => void;
onPromptPickerClose?: () => void;
onMentionPickerClose?: () => void;
onMentionOpened?: () => void;
@@ -24,6 +35,11 @@
}
let {
isCommandPickerOpen,
commandQuery,
commands = [],
onCommandPickerClose,
onCommandSelect,
isPromptPickerOpen,
promptSearchQuery,
isMentionPickerOpen,
@@ -39,14 +55,16 @@
onPromptLoadError
}: Props = $props();
let commandPickerRef: ChatFormCommandPicker | undefined = $state(undefined);
let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
let mentionPickerRef: ChatFormMentionPicker | undefined = $state(undefined);
/**
* Delegates keyboard events to the active picker child.
* Returns true if the event was handled.
*/
/** Delegate keyboard events to the active picker child; true if handled. */
export function handleKeydown(event: KeyboardEvent): boolean {
if (isCommandPickerOpen && commandPickerRef?.handleKeydown(event)) {
return true;
}
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
return true;
}
@@ -59,6 +77,15 @@
}
</script>
<ChatFormCommandPicker
bind:this={commandPickerRef}
isOpen={isCommandPickerOpen ?? false}
query={commandQuery ?? ''}
{commands}
onClose={onCommandPickerClose ?? (() => {})}
onSelect={onCommandSelect ?? (() => {})}
/>
<ChatFormPickerMcpPrompts
bind:this={promptPickerRef}
isOpen={isPromptPickerOpen}
@@ -28,11 +28,10 @@
onMount(() => {
if (textareaElement) {
autoResizeTextarea(textareaElement);
textareaElement.focus();
textareaElement.focus({ preventScroll: true });
}
});
// Expose the textarea element for external access
export function getElement() {
return textareaElement;
}
@@ -49,7 +48,8 @@
}
}
// Plain-text caret offsets for the mention-splice flow.
// Plain-text caret offsets, shared with the contenteditable variant so
// the picker/paste flows can address either renderer through one handle.
export function getCaretOffset(): number {
if (!textareaElement) return 0;
return textareaElement.selectionStart ?? textareaElement.value.length;
@@ -1,7 +1,5 @@
<script lang="ts">
import { FolderOpen } from '@lucide/svelte';
import { untrack } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { ToolsService } from '$lib/services/tools.service';
import { toolsStore } from '$lib/stores/tools.svelte';
import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
@@ -10,23 +8,22 @@
buildCaseInsensitiveGlob,
joinPath,
lastPathSegment,
rankEntries,
splitPathQuery,
runGlobSearchWithChildren,
type GlobEntry
} from '$lib/utils';
import { debounce } from '$lib/utils/debounce';
import * as Popover from '$lib/components/ui/popover';
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte';
import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.svelte';
import {
DEFAULT_MOBILE_BREAKPOINT,
GLOB_WILDCARD,
HOME_TILDE,
MAX_RESULTS_SHOWN,
NATIVE_LIMIT,
NATIVE_MAX_DEPTH,
PATH_NAV_MAX_DEPTH,
SEARCH_DEBOUNCE_MS,
SEARCH_LIMIT,
SEARCH_MAX_DEPTH
@@ -39,228 +36,147 @@
class?: string;
disabled?: boolean;
directory?: string | null;
/** Controlled open state; the host owns it so the chip click and the
* `/cwd` slash command open the picker through the same path. */
isOpen: boolean;
/** Two-way bound query, kept in sync with the text after `/cwd `. */
query: string;
/** Anchor at the form's top edge so the popover floats above the box. */
customAnchor?: HTMLElement | null;
onChange?: (directory: string | null) => void;
/**
* Lets the host refocus the chat input so typing can resume without
* an extra click after the popover closes.
*/
/** Lets the host refocus the chat input after the popover closes. */
onClose?: () => void;
/** Fired when the chip is clicked so the host can open the picker. */
onOpen?: () => void;
}
let {
class: className = '',
disabled = false,
directory = $bindable(null),
directory = null,
isOpen,
query = $bindable(''),
customAnchor = null,
onChange,
onClose
onClose,
onOpen
}: Props = $props();
// File System Access API is opt-in: when available (Chrome / Edge / Opera) the popover
// exposes a "Browse" button that opens the native folder picker. When unavailable the
// popover still works via the text input - no alerts, no upload semantics.
// File System Access API is opt-in (Chrome / Edge / Opera): the popover
// exposes a "Browse" button only when available.
const pickerSupported =
typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function';
// Popover open state; the element handles outside-click and Escape.
let isOpen = $state(false);
let inputValue = $state('');
let searchInputRef: HTMLInputElement | null = $state(null);
let queryResults = $state<string[]>([]);
let isSearching = $state(false);
let searchError = $state<string | null>(null);
let hoveredIndex = $state(-1);
// Bumped only by ArrowUp/ArrowDown handlers; the list scrolls the
// highlighted row into view only via this trigger, never on hover.
let scrollTrigger = $state(0);
let listContainer = $state<HTMLDivElement | null>(null);
// Absolute home directory on the server, resolved once per session by
// the tools store. Anchors both the search scope and the chip's `~`
// abbreviation.
const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => queryResults.length,
onClose: closePicker,
onSelect: (index) => commit(queryResults[index])
});
let homeBase = $derived(toolsStore.serverHome);
// AbortController + sequence counter to discard stale responses when the user
// keeps typing; a newer call aborts the previous one. The sequence counter
// also covers the gap between abort and the catch handler.
let searchController: AbortController | null = null;
let searchSeq = 0;
// Cache of the last file_glob_search result per (parent, include, max_depth),
// so repeated queries in the same directory don't re-walk the tree. Entering
// a directory hits it every time: the children listed for an exactly typed
// segment are what the next keystroke, the trailing slash, asks for again.
const SEARCH_CACHE_TTL_MS = 2000;
const searchCache = new SvelteMap<string, { results: GlobEntry[]; base: string; at: number }>();
const runSearch = debounce((query: string) => {
void doSearch(query);
}, SEARCH_DEBOUNCE_MS);
// Resolve home eagerly on mount so the chip can abbreviate before the
// user opens the picker. resolveServerHome() is cached, so repeat calls
// (e.g. from handleOpenChange) are no-ops.
// Resolve home eagerly so the chip can abbreviate before the picker opens.
$effect(() => {
if (typeof window === 'undefined') return;
void toolsStore.resolveServerHome();
});
// Auto-focus the search input when the popover opens.
// HTML `autofocus` is unreliable on dynamically shown elements, so we
// use a microtask (0ms setTimeout) after the effect flushes.
// HTML `autofocus` is unreliable on dynamically shown elements.
$effect(() => {
if (!isOpen) return;
setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
});
let lastScrollTrigger: number | null = null;
// hoveredIndex/queryResults are untracked so hover and result replacement
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger
$effect(() => {
if (scrollTrigger === lastScrollTrigger) return;
lastScrollTrigger = scrollTrigger;
untrack(() => {
if (!listContainer) return;
if (hoveredIndex < 0 || hoveredIndex >= queryResults.length) return;
const selectedElement = listContainer.querySelector(
`[data-result-index="${hoveredIndex}"]`
) as HTMLElement | null;
selectedElement?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
if (!isOpen) return;
const q = query.trim();
nav.reset(-1);
if (q) {
search.run(q);
} else {
search.cancel();
queryResults = [];
searchError = null;
nav.reset(-1);
searchScope = homeBase ?? HOME_TILDE;
}
});
function cancelSearch() {
searchController?.abort();
searchSeq++;
isSearching = false;
}
useScrollActiveRow({
getTrigger: () => nav.scrollTrigger,
getContainer: () => listContainer,
getIndex: () => nav.hoveredIndex,
getCount: () => queryResults.length,
dataIndex: 'result'
});
// Effective directory the current search runs against (shown in the
// footer); updated by doSearch, including when an exactly-typed
// directory is "entered".
let searchScope = $state(HOME_TILDE);
// Runs a directory listing through the cache, so a repeated query in the
// same directory does not re-walk the tree on the server.
async function searchDirs(
path: string,
include: string,
maxDepth: number,
signal: AbortSignal
): Promise<{ base: string; entries: GlobEntry[]; error?: string }> {
const key = `${path}\u0000${include}\u0000${maxDepth}`;
const cached = searchCache.get(key);
if (cached && Date.now() - cached.at < SEARCH_CACHE_TTL_MS) {
return { base: cached.base, entries: cached.results };
}
const res = await ToolsService.executeToolRaw(
BuiltInTool.FILE_GLOB_SEARCH,
{ path, type: GlobSearchType.DIR, include, max_depth: maxDepth, limit: SEARCH_LIMIT },
signal
);
if (typeof res.error === 'string') return { base: '', entries: [], error: res.error };
const base = typeof res.base === 'string' ? res.base : '';
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
const now = Date.now();
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 };
}
async function doSearch(query: string) {
const trimmed = query.trim();
if (!trimmed) {
queryResults = [];
searchError = null;
isSearching = false;
hoveredIndex = -1;
searchScope = homeBase ?? HOME_TILDE;
return;
}
cancelSearch();
const controller = new AbortController();
searchController = controller;
const mySeq = ++searchSeq;
const pathQuery = splitPathQuery(trimmed);
isSearching = true;
try {
// A generous limit is requested because ranking happens
// client-side; only the top 20 are shown.
const searchPath = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
const include = pathQuery
? pathQuery.last
? buildCaseInsensitiveGlob(pathQuery.last)
: GLOB_WILDCARD
: buildCaseInsensitiveGlob(trimmed);
const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : SEARCH_MAX_DEPTH;
const res = await searchDirs(searchPath, include, maxDepth, controller.signal);
if (mySeq !== searchSeq) return;
if (res.error) {
// An exactly-typed directory is "entered": the shared search lists its
// children too, so path navigation does not require a trailing slash.
const search = useDebouncedSearch({
debounceMs: SEARCH_DEBOUNCE_MS,
canRun: () => isOpen,
getQuery: () => query.trim(),
run: async (q, signal, isCurrent) => {
const trimmed = q.trim();
if (!trimmed) {
queryResults = [];
hoveredIndex = -1;
searchError = res.error;
searchError = null;
nav.reset(-1);
searchScope = homeBase ?? HOME_TILDE;
return;
}
const { base, entries } = res;
const ranked = rankEntries(entries, pathQuery?.last ?? trimmed);
let results = ranked.map((e) => joinPath(base, e.path));
searchScope = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
// An exactly-typed directory is "entered": list its children too,
// so path navigation doesn't require a trailing slash.
const last = pathQuery?.last;
const exact = last
? ranked.find((e) => lastPathSegment(e.path).toLowerCase() === last.toLowerCase())
: undefined;
if (exact) {
const exactDir = joinPath(base, exact.path);
const childRes = await searchDirs(
exactDir,
GLOB_WILDCARD,
PATH_NAV_MAX_DEPTH,
controller.signal
try {
// Generous limit: ranking is client-side, only the top
// MAX_RESULTS_SHOWN are shown.
const res = await runGlobSearchWithChildren(
trimmed,
homeBase ?? HOME_TILDE,
SEARCH_MAX_DEPTH,
SEARCH_LIMIT,
signal,
{ type: GlobSearchType.DIR }
);
if (mySeq !== searchSeq) return;
if (!childRes.error) {
const children = childRes.entries
.map((e) => joinPath(childRes.base, e.path))
.sort((a, b) => a.localeCompare(b));
results = [...results, ...children];
searchScope = exactDir;
if (!isCurrent()) return;
if (res.error) {
queryResults = [];
nav.reset(-1);
searchError = res.error;
return;
}
searchScope = res.exactDir ?? res.args.path;
queryResults = res.entries.map((e) => e.path).slice(0, MAX_RESULTS_SHOWN);
if (queryResults.length > 0) {
nav.reset(0);
nav.bumpScroll(); // scroll the list back to the top (first item is hovered)
} else {
nav.reset(-1);
}
searchError = null;
} catch (err) {
if (!isCurrent() || signal.aborted) return;
queryResults = [];
nav.reset(-1);
searchError = err instanceof Error ? err.message : String(err);
}
queryResults = results.slice(0, MAX_RESULTS_SHOWN);
hoveredIndex = queryResults.length > 0 ? 0 : -1;
// new results: scroll the list back to the top (first item is hovered)
if (hoveredIndex === 0) scrollTrigger++;
searchError = null;
} catch (err) {
if (mySeq !== searchSeq) return;
queryResults = [];
hoveredIndex = -1;
if (controller.signal.aborted) return;
searchError = err instanceof Error ? err.message : String(err);
} finally {
if (mySeq === searchSeq) isSearching = false;
}
}
// Single funnel for every local close so the host refocus fires
// regardless of which commit/dismiss path ended the interaction.
});
// Single funnel for every local close so the host refocus always fires.
function closePicker() {
isOpen = false;
onClose?.();
}
function commit(path: string) {
directory = path;
onChange?.(path);
closePicker();
}
@@ -268,15 +184,12 @@
function setDirectory(value: string) {
const trimmed = value.trim();
if (!trimmed) return;
directory = trimmed;
onChange?.(trimmed);
}
// Resolve a folder name picked via the browser-native picker (which exposes
// only the leaf name) to a server-side absolute path. Returns null when the
// server cannot locate a matching directory, so the caller can fail visibly
// instead of committing a bare leaf name that would resolve against the
// server process working directory.
// Resolve a browser-picked folder name (which exposes only the leaf name)
// to a server-side absolute path; null when the server cannot locate it,
// so the caller fails visibly instead of committing a bare leaf name.
async function resolveNativeName(name: string): Promise<string | null> {
try {
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
@@ -318,7 +231,7 @@
}
function handleSubmit() {
const value = inputValue.trim();
const value = query.trim();
if (!value) {
closePicker();
return;
@@ -330,47 +243,33 @@
function handleKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
// Commit the highlighted result, falling back to the raw input
// only when the query returned no matches.
if (hoveredIndex >= 0 && queryResults[hoveredIndex]) {
commit(queryResults[hoveredIndex]);
if (nav.hoveredIndex >= 0 && queryResults[nav.hoveredIndex]) {
commit(queryResults[nav.hoveredIndex]);
} else if (queryResults.length === 0) {
handleSubmit();
}
} else if (event.key === KeyboardKey.ARROW_DOWN) {
if (queryResults.length > 0) {
event.preventDefault();
hoveredIndex = (hoveredIndex + 1) % queryResults.length;
scrollTrigger++;
nav.move(1);
}
} else if (event.key === KeyboardKey.ARROW_UP) {
if (queryResults.length > 0) {
event.preventDefault();
hoveredIndex = hoveredIndex <= 0 ? queryResults.length - 1 : hoveredIndex - 1;
scrollTrigger++;
nav.move(-1);
}
}
}
function handleInputInput(value: string) {
hoveredIndex = -1;
if (value.trim().length > 0) {
runSearch(value);
}
}
function clearDirectory(event?: MouseEvent) {
// Stop the click from bubbling into the popover trigger and re-opening
// Stop the click from bubbling into the chip button and re-opening
// the picker on top of the now-cleared state.
event?.stopPropagation();
event?.preventDefault();
directory = null;
onChange?.(null);
closePicker();
}
// The chip is always visible; the X clears the directory (no-op when
// already empty).
function handleDismiss(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
@@ -380,105 +279,104 @@
}
function handleOpenChange(open: boolean) {
isOpen = open;
if (open) {
// Seed the search field with the current path so the user can refine it
// (or hit Enter to confirm / clear via the X icon).
inputValue = directory ?? '';
hoveredIndex = -1;
queryResults = [];
searchError = null;
void toolsStore.resolveServerHome();
searchScope = homeBase ?? HOME_TILDE;
if (inputValue.trim()) runSearch(inputValue);
} else {
cancelSearch();
// bits-ui-initiated close (Escape on the content, outside-click,
// trigger toggle) - the only path that bypasses closePicker().
search.cancel();
// bits-ui-initiated close (Escape on the content, outside-click) -
// the only path that bypasses closePicker().
onClose?.();
}
}
// Tooltips only on wider viewports - hover surfaces get in the way on
// touch / narrow layouts. Mirrors the gate used in ActionIcon.
let innerWidth = $state(0);
const showTooltip = $derived(innerWidth > DEFAULT_MOBILE_BREAKPOINT);
</script>
<div
<button
type="button"
class={[
'justify-self-start flex min-w-0 w-auto items-center gap-1 mt-1.5 py-1 px-2 backdrop-blur-2xl rounded-md',
className,
isOpen && 'w-full'
className
]}
onclick={onOpen}
{disabled}
>
<Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
<Popover.Trigger {disabled} class="flex justify-start">
<ChatFormWorkingDirectoryChip
{directory}
{homeBase}
{disabled}
{showTooltip}
onClear={handleDismiss}
<ChatFormWorkingDirectoryChip
{directory}
{homeBase}
{disabled}
{showTooltip}
onClear={handleDismiss}
/>
</button>
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
<Popover.Trigger
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
aria-hidden="true"
>
<span class="sr-only">Open working directory picker</span>
</Popover.Trigger>
<Popover.Content
side="top"
align="start"
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"
>
<div class="p-2 min-h-22 flex flex-col justify-between">
<SearchInput
bind:ref={searchInputRef}
bind:value={query}
placeholder="Choose working directory"
onClose={closePicker}
class="w-full"
/>
</Popover.Trigger>
<Popover.Content
side="top"
align="start"
sideOffset={4}
class="md:max-w-3xl w-[calc(100vw-1rem)] rounded-xl border-border/50 p-0 shadow-xl md:-translate-2!"
onkeydown={handleKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div class="p-2 min-h-28 flex flex-col justify-between">
<SearchInput
bind:ref={searchInputRef}
bind:value={inputValue}
placeholder="Choose working directory"
onInput={handleInputInput}
onClose={closePicker}
class="w-full"
{#if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
<ChatFormWorkingDirectoryResultsList
results={queryResults}
hoveredIndex={nav.hoveredIndex}
isSearching={search.isSearching}
error={searchError}
rawQuery={query}
bind:container={listContainer}
onCommit={commit}
onHover={(index) => nav.setHover(index)}
/>
{/if}
{#if inputValue.trim() && (isSearching || queryResults.length > 0 || searchError)}
<ChatFormWorkingDirectoryResultsList
results={queryResults}
{hoveredIndex}
{isSearching}
error={searchError}
rawQuery={inputValue}
bind:container={listContainer}
onCommit={commit}
onHover={(index) => (hoveredIndex = index)}
/>
{/if}
{#if pickerSupported}
<button
type="button"
class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onclick={browseNative}
>
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
<span>Browse</span>
</button>
{/if}
{#if pickerSupported}
<button
type="button"
class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onclick={browseNative}
{#if homeBase}
<div class="-mx-2 my-2 h-px bg-border/20" aria-hidden="true"></div>
<span class="px-2 py-1.5 font-mono text-[10px]">
Searching in:
<span class="truncate text-muted-foreground/70" title={searchScope}
>{abbreviateHome(searchScope, homeBase)}</span
>
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
<span>Browse</span>
</button>
{/if}
{#if homeBase}
<div class="-mx-2 my-1 h-px bg-border/20" aria-hidden="true"></div>
<span class="px-2 py-2 font-mono text-[10px]">
Searching in:
<span class="truncate text-muted-foreground/70" title={searchScope}
>{abbreviateHome(searchScope, homeBase)}</span
>
</span>
{/if}
</div>
</Popover.Content>
</Popover.Root>
</div>
</span>
{/if}
</div>
</Popover.Content>
</Popover.Root>
<svelte:window bind:innerWidth />
@@ -1,6 +1,7 @@
<script lang="ts">
import { Folder, X } from '@lucide/svelte';
import { abbreviateWorkingDir } from '$lib/utils';
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ActionIcon } from '$lib/components/app/actions';
@@ -21,7 +22,7 @@
}: Props = $props();
const displayLabel = $derived(
directory ? abbreviateWorkingDir(directory, homeBase) : 'Select working directory'
directory ? abbreviateWorkingDir(directory, homeBase) : SET_WORKING_DIRECTORY_LABEL
);
// Full path surface: hover the abbreviated label to recall the exact directory.
const displayLabelTitle = $derived(directory ?? '');
@@ -183,8 +183,8 @@
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="bottom" />
{/if}
<div class="info my-6 grid gap-4 tabular-nums">
{#if displayedModel}
{#if displayedModel}
<div class="info my-6 grid gap-4 tabular-nums">
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
<ChatMessageAssistantModel
{displayedModel}
@@ -200,8 +200,8 @@
showMessageStats={currentConfig.showMessageStats}
/>
</div>
{/if}
</div>
</div>
{/if}
{#if message.timestamp && !editCtx.isEditing}
<ChatMessageActionIcons
@@ -164,7 +164,7 @@
? `max-height: ${MAX_HEIGHT}px;`
: 'max-height: none;'}
>
{#if currentConfig.renderUserContentAsMarkdown}
{#if !currentConfig.renderContentAsRawText}
<div bind:this={messageElement} class={isExpanded ? 'cursor-text' : ''}>
<MarkdownContent class="markdown-system-content" content={message.content} />
</div>
@@ -98,9 +98,10 @@
showSpinner || (toolUi?.icon ?? null) || !mcpServerFavicon ? null : mcpServerFavicon
);
// No subtitle while the call is in flight - the spinner already
// signals activity; only terminal states get a pill.
function subtitleFor(errorMessage?: string): string | undefined {
if (extraLiveStreaming) return 'streaming...';
if (showSpinner) return 'executing...';
if (showSpinner) return undefined;
if (errorMessage) return 'failed';
if (isStreamingCall && !isStreaming) return 'incomplete';
return undefined;
@@ -63,7 +63,7 @@
data-multiline={isMultiline ? '' : undefined}
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
{#if renderMarkdown && currentConfig.renderUserContentAsMarkdown}
{#if renderMarkdown && !currentConfig.renderContentAsRawText}
<div bind:this={messageElement}>
<MarkdownContent class="markdown-user-content" {content} />
</div>
@@ -41,7 +41,6 @@
let expandedStates: Record<number, boolean> = $state({});
const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean);
const showThoughtInProgress = $derived(Boolean(config().showThoughtInProgress));
const alwaysShowToolCallContent = $derived(Boolean(config().alwaysShowToolCallContent));
const showMessageStats = $derived(Boolean(config().showMessageStats));
@@ -186,7 +185,6 @@
{section}
open={isExpanded(index, section)}
{isStreaming}
{renderThinkingAsMarkdown}
{hasReasoningError}
attachments={message?.extra}
onToggle={() => toggleExpanded(index, section)}
@@ -3,6 +3,7 @@
import { CollapsibleContentBlock, MarkdownContent } from '$lib/components/app';
import { AgenticSectionType } from '$lib/enums';
import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
import { config } from '$lib/stores/settings.svelte';
import type { DatabaseMessageExtra } from '$lib/types';
import type { AgenticSection } from '$lib/utils';
@@ -10,7 +11,6 @@
section: AgenticSection;
open: boolean;
isStreaming: boolean;
renderThinkingAsMarkdown: boolean;
hasReasoningError?: boolean;
attachments?: DatabaseMessageExtra[];
onToggle?: () => void;
@@ -20,12 +20,13 @@
section,
open,
isStreaming,
renderThinkingAsMarkdown,
hasReasoningError = false,
attachments,
onToggle
}: Props = $props();
const currentConfig = config();
const REASONING_HEADER = 'Reasoning';
const REASONING_HEADER_PENDING = 'Reasoning...';
const REASONING_SUBTITLE_ERROR = 'Error';
@@ -128,7 +129,7 @@
class:is-streaming={isPending}
onscroll={handleScrollEvent}
>
{#if renderThinkingAsMarkdown}
{#if !currentConfig.renderContentAsRawText}
<MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
{:else}
<div
+21 -9
View File
@@ -120,7 +120,8 @@ export { default as ChatAttachmentsPreviewCurrentItem } from './ChatAttachments/
* Used by ChatScreenForm and ChatMessageEditForm for both new conversations and message editing.
*
* **Architecture:**
* - Composes ChatFormTextarea, ChatFormActions, and ChatFormPickerMcpPrompts
* - Composes ChatFormTextarea (or ChatFormContenteditable for messages with
* file mention links), ChatFormActions, and ChatFormPickerMcpPrompts
* - Manages file upload state via `uploadedFiles` bindable prop
* - Integrates with ModelsSelectorDropdown for model selection in router mode
* - Communicates with parent via callbacks (onSubmit, onFilesAdd, onStop, etc.)
@@ -266,9 +267,16 @@ export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileIn
export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte';
/**
* Auto-resizing textarea with IME composition support. Automatically adjusts
* height based on content. Handles IME input correctly (waits for composition
* end before processing Enter key). Exposes focus() and resetHeight() methods.
* Auto-resizing contenteditable input that renders `[name](file://...)`
* mention links as inline chips while keeping the value as the markdown
* source string. ChatForm swaps it in once a mention link lands in the
* buffer. Shares the focus()/resetHeight()/caret handle with the textarea.
*/
export { default as ChatFormContenteditable } from './ChatForm/ChatFormContenteditable.svelte';
/**
* Plain auto-resizing textarea with IME composition support. Default input
* renderer inside ChatForm until a file mention lands.
*/
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
@@ -384,11 +392,15 @@ export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPi
export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
/**
* **ChatFormPickers** - Chat input picker container
*
* 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.
* `/`-triggered slash-command picker. Lists the available slash commands
* (`/prompt`, `/cwd`, `/model`) filtered by the typed query; selection
* hands the command to the parent for dispatch.
*/
export { default as ChatFormCommandPicker } from './ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte';
/**
* Hosts the chat-form pickers (slash-command, MCP prompt, file mention)
* and delegates keyboard events to the active one.
*/
export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte';
@@ -34,7 +34,8 @@
preprocessLaTeX,
getImageErrorFallbackHtml,
copyCodeToClipboard,
copyToClipboard
copyToClipboard,
splitGluedClosingCodeFences
} from '$lib/utils';
import {
IMAGE_NOT_ERROR_BOUND_SELECTOR,
@@ -342,7 +343,11 @@
* Incomplete code blocks are rendered using SyntaxHighlightedCode to maintain interactivity.
* @param markdown - The raw markdown string to process
*/
async function processMarkdown(markdown: string) {
async function processMarkdown(rawMarkdown: string) {
// Text glued to a closing code fence is not a fence to the parser -
// the block would swallow it. Split it onto its own line first.
const markdown = splitGluedClosingCodeFences(rawMarkdown);
// Early exit if content unchanged (can happen with rapid coalescing)
if (markdown === previousContent) {
return;
@@ -243,7 +243,6 @@ div.markdown-user-content :global(.table-wrapper) {
/* Code blocks */
.markdown-content :global(.code-block-wrapper) {
margin: 1.5rem 0;
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
@@ -253,6 +252,14 @@ div.markdown-user-content :global(.table-wrapper) {
max-height: var(--max-message-height);
}
.markdown-content .markdown-block:not(:first-child) :global(.code-block-wrapper) {
margin-top: 1rem;
}
.markdown-content .markdown-block:not(:last-child) :global(.code-block-wrapper) {
margin-bottom: 1rem;
}
.markdown-content:global(.dark) :global(.code-block-wrapper) {
border-color: color-mix(in oklch, var(--border) 20%, transparent);
}
@@ -1,7 +1,7 @@
/**
* Rehype plugin that rewrites `file://` markdown anchors into the inline
* @-mention chip, reusing the visual contract from
* `$lib/constants/mention-badge`.
* mention chip, sharing the class string with the contenteditable
* tokenizer via `$lib/constants/mention-badge`.
*
* The chip is presentational: `file://` navigation is blocked from
* http(s) pages, so the anchor becomes a plain `<span>` (no link role,
@@ -1,8 +1,9 @@
<script lang="ts">
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
import { ChevronDown, Loader2 } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { MODEL_SELECTOR_ICON } from '$lib/constants';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
import { modelLoadFraction } from '$lib/utils';
@@ -35,7 +36,7 @@
}: Props = $props();
let isOpen = $state(false);
let highlightedIndex = $state<number>(-1);
let highlightedId = $state<string | null>(null);
const ms = useModelsSelector({
currentModel: () => currentModel,
@@ -43,15 +44,77 @@
onModelChange: () => onModelChange,
onOpenChange: (open) => {
isOpen = open;
highlightedIndex = -1;
highlightedId = null;
}
});
$effect(() => {
void ms.searchTerm;
highlightedIndex = -1;
highlightedId = null;
});
// Focus the dropdown's search box without scrolling the page. bits-ui
// auto-focuses the opened content by default, which can yank the page
// scroll; we prevent that on the Content and refocus the search here.
$effect(() => {
if (!isOpen) return;
requestAnimationFrame(() => {
const search = document.querySelector<HTMLElement>(
'[data-slot="dropdown-menu-content"] input'
);
search?.focus({ preventScroll: true });
});
});
// Keyboard navigation follows the on-screen row order, not the flat option list order.
let visualOrder = $derived.by(() => {
const order: string[] = [];
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
for (const group of ms.groupedFilteredOptions.available) {
for (const item of group.items) order.push(item.option.id);
}
return order;
});
let highlightedIndex = $derived(highlightedId ? visualOrder.indexOf(highlightedId) : -1);
function moveHighlight(direction: 1 | -1) {
const len = visualOrder.length;
if (len === 0) {
highlightedId = null;
return;
}
let index = highlightedIndex;
if (index === -1) {
index = direction === 1 ? 0 : len - 1;
} else {
index = (index + direction + len) % len;
}
highlightedId = visualOrder[index];
}
// Alt+Enter only unloads and keeps the dropdown open.
async function handleModelKeyAction(modelId: string, unload: boolean) {
if (!unload) {
void ms.handleSelect(modelId);
return;
}
const model = routerModels().find((m) => m.id === modelId);
const status = model?.status?.value as ServerModelStatus | undefined;
if (status === ServerModelStatus.LOADING) return;
await modelsStore.unloadModel(modelId);
}
export function open() {
ms.handleOpenChange(true);
}
@@ -61,33 +124,17 @@
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (ms.filteredOptions.length === 0) return;
if (highlightedIndex === -1 || highlightedIndex === ms.filteredOptions.length - 1) {
highlightedIndex = 0;
} else {
highlightedIndex += 1;
}
moveHighlight(1);
} else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (ms.filteredOptions.length === 0) return;
if (highlightedIndex === -1 || highlightedIndex === 0) {
highlightedIndex = ms.filteredOptions.length - 1;
} else {
highlightedIndex -= 1;
}
moveHighlight(-1);
} else if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < ms.filteredOptions.length) {
const option = ms.filteredOptions[highlightedIndex];
ms.handleSelect(option.id);
} else if (ms.filteredOptions.length > 0) {
highlightedIndex = 0;
if (highlightedId) {
void handleModelKeyAction(highlightedId, event.altKey);
} else if (visualOrder.length > 0) {
highlightedId = visualOrder[0];
}
}
}
@@ -109,7 +156,7 @@
]}
style="max-width: min(calc(100cqw - 10rem), 20rem)"
>
<Package class="h-3.5 w-3.5 shrink-0" />
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
</span>
{:else}
<p class="text-xs text-muted-foreground">No models available.</p>
@@ -150,7 +197,7 @@
]}
disabled={disabled || ms.updating}
>
<Package class="h-3.5 w-3.5 shrink-0" />
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption}
<ModelId
@@ -186,6 +233,7 @@
<DropdownMenu.Content
align="end"
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuSearchable
searchValue={ms.searchTerm}
@@ -217,9 +265,9 @@
{/if}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
{@const { option, flatIndex } = item}
{@const { option } = item}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
{@const isHighlighted = flatIndex === highlightedIndex}
{@const isHighlighted = option.id === highlightedId}
{@const isFav = ms.isFavorite(option.model)}
<ModelsSelectorOption
@@ -230,11 +278,11 @@
{hideOrgName}
onSelect={ms.handleSelect}
onInfoClick={ms.handleInfoClick}
onMouseEnter={() => (highlightedIndex = flatIndex)}
onMouseEnter={() => (highlightedId = option.id)}
onKeyDown={(event) => {
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
event.preventDefault();
ms.handleSelect(option.id);
void handleModelKeyAction(option.id, event.altKey);
}
}}
/>
@@ -275,7 +323,7 @@
onclick={() => ms.handleOpenChange(true)}
disabled={disabled || ms.updating}
>
<Package class="h-3.5 w-3.5 shrink-0" />
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption}
<ModelId
@@ -62,9 +62,10 @@
<div
class={[
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
'cursor-pointer hover:bg-muted focus:bg-muted',
(isSelected || isHighlighted) && 'bg-accent text-accent-foreground',
!(isSelected || isHighlighted) && 'hover:bg-accent hover:text-accent-foreground',
'cursor-pointer',
isSelected && 'bg-accent/50 text-accent-foreground',
isHighlighted && 'bg-accent',
!isSelected && !isHighlighted && 'hover:bg-muted',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
]}
role="option"
@@ -98,7 +98,12 @@
const numValue = Number(processedConfig[field]);
if (!isNaN(numValue)) {
if ((POSITIVE_INTEGER_FIELDS as readonly string[]).includes(field)) {
processedConfig[field] = Math.max(1, Math.round(numValue));
const entryByMinMax = SETTINGS_CHAT_SECTIONS.flatMap(
(section) => section.fields ?? []
).find((entry) => entry.key === field);
const lo = entryByMinMax?.min ?? 1;
const hi = entryByMinMax?.max ?? Number.POSITIVE_INFINITY;
processedConfig[field] = Math.max(lo, Math.min(hi, Math.round(numValue)));
} else {
processedConfig[field] = numValue;
}
@@ -83,12 +83,18 @@
<Input
id={field.key}
type={field.isPositiveInteger ? 'number' : 'text'}
{...field.isPositiveInteger ? { min: '1', step: '1' } : {}}
{...field.isPositiveInteger
? {
min: String(field.min ?? 1),
step: '1',
...(field.max != null ? { max: String(field.max) } : {})
}
: {}}
value={currentValue}
oninput={(e) => onConfigChange(field.key, e.currentTarget.value)}
placeholder={currentModelParams[field.key] != null
? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}`
: ''}
: (field.placeholder ?? '')}
class="w-full {isCustomRealTime ? 'pr-8' : ''}"
/>
{#if isCustomRealTime}
@@ -0,0 +1,44 @@
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants/working-directory';
import { ChatFormCommandAction } from '$lib/enums';
import type { ChatFormCommand } from '$lib/types';
interface ChatCommandsOptions {
/** Gates `/model`. */
showModelSelector: boolean;
/** Gates `/prompt`. */
hasPrompts: () => boolean;
/** Gates `/cwd`. */
hasCwdTools: () => boolean;
}
/**
* The slash commands surfaced by the `/` command picker, in display order.
*
* Availability is supplied as predicates rather than store imports: this
* module is re-exported through the `$lib/constants` barrel, and importing
* stores at module load would create a circular dependency (the stores
* themselves import from `$lib/constants`).
*/
export function getChatCommands(options: ChatCommandsOptions): ChatFormCommand[] {
return [
{
name: 'prompt',
description: 'Insert an MCP prompt',
action: ChatFormCommandAction.PROMPT,
disabled: !options.hasPrompts()
},
{
name: 'cwd',
description: SET_WORKING_DIRECTORY_LABEL,
keywords: ['current working directory'],
action: ChatFormCommandAction.CWD,
disabled: !options.hasCwdTools()
},
{
name: 'model',
description: 'Select model',
action: ChatFormCommandAction.MODEL,
disabled: !options.showModelSelector
}
];
}
@@ -19,6 +19,10 @@ export const PANEL_CLASSES = `
export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
export const DIALOG_SUBMENU_CONTENT = 'w-60';
/** Selects the focused chat-form input (either renderer) to restore focus after model actions. */
export const CHAT_INPUT_FOCUS_SELECTOR =
'[data-slot="input-area"] textarea, [data-slot="input-area"] [contenteditable="true"]';
/** Default Tailwind size class for inline icon components (lucide, etc.). */
export const ICON_CLASS_DEFAULT = 'h-4 w-4';
+1
View File
@@ -18,6 +18,7 @@ export * from './binary-detection';
export * from './built-in-tools';
export * from './cache';
export * from './chat-form';
export * from './chat-commands';
export * from './cli-flags';
export * from './code-blocks';
export * from './icons';
+9 -6
View File
@@ -1,8 +1,9 @@
/**
* Visual contract for message @-mention badges. Svelte cannot be mounted
* from a hast tree, so the rehype file-badge plugin emits the shared class
* string below; keeping it here as a literal lets Tailwind's source
* scanner generate the utility classes.
* Shared visual contract between the two DOM-only badge paths (the
* contenteditable tokenizer + the rehype plugin). Svelte cannot be
* mounted at the per-keystroke tokenizer hot path nor from a hast tree,
* so both emit the badge with the same class string literal; Tailwind's
* scanner picks it up in both sources.
*/
export const MENTION_BADGE_CLASSNAME =
'inline-flex w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md border border-border/50 bg-foreground/5 px-1.5 py-0.5 text-xs font-mono text-foreground hover:bg-foreground/10 dark:bg-foreground/10 dark:text-secondary-foreground';
@@ -10,8 +11,10 @@ export const MENTION_BADGE_CLASSNAME =
export const MENTION_BADGE_ICON_CLASSNAME = 'h-3 w-3 shrink-0';
/**
* SVG attributes shared by the hast-built badge icons; the rehype plugin
* spreads them onto the `<svg>` `properties`.
* SVG attributes shared by the DOM-built and hast-built badge icons.
* The tokenizer applies them via `setAttribute`, the rehype plugin
* spreads them onto the hast `<svg>` `properties`; string values are
* valid for both.
*/
export const MENTION_BADGE_SVG_ATTRIBUTES: Readonly<Record<string, string>> = {
xmlns: 'http://www.w3.org/2000/svg',
+1 -2
View File
@@ -23,7 +23,7 @@ export const SETTINGS_KEYS = {
SHOW_AGENTIC_TURN_STATS: 'showAgenticTurnStats',
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
RENDER_CONTENT_AS_RAW_TEXT: 'renderContentAsRawText',
DISABLE_AUTO_SCROLL: 'disableAutoScroll',
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
@@ -33,7 +33,6 @@ export const SETTINGS_KEYS = {
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',
@@ -233,21 +233,13 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
section: SETTINGS_SECTION_SLUGS.DISPLAY
},
{
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
label: 'Render user content as Markdown',
help: 'Render user messages using markdown formatting in the chat.',
key: SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT,
label: 'Render content as raw text',
help: 'Display user, system and thinking content as plain text instead of formatted Markdown. Markdown is the default so that @-mention badges render in sent messages.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY
},
{
key: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN,
label: 'Render thinking as Markdown',
help: 'Render the reasoning/thinking block content as formatted Markdown instead of plain text.',
defaultValue: true,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY
},
{
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
label: 'Use full height code blocks',
@@ -724,6 +716,9 @@ export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
type: s.type,
isExperimental: s.isExperimental,
isPositiveInteger: s.isPositiveInteger,
placeholder: s.placeholder,
min: s.min,
max: s.max,
dependsOn: s.dependsOn,
help: s.help,
options: s.options,
+4 -1
View File
@@ -1,4 +1,4 @@
import { Search, Settings, SquarePen } from '@lucide/svelte';
import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
import type { Component } from 'svelte';
import { ROUTES } from './routes';
@@ -6,6 +6,9 @@ import { ROUTES } from './routes';
export const FORK_TREE_DEPTH_PADDING = 8;
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
/** Icon used for the model selector and the `/model` slash command. */
export const MODEL_SELECTOR_ICON = Package;
export const ICON_STRIP_TRANSITION_DURATION = 150;
export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
@@ -8,6 +8,9 @@
export const GLOB_WILDCARD = '*';
/** Label shown for the working-directory picker / `/cwd` slash command. */
export const SET_WORKING_DIRECTORY_LABEL = 'Set working directory';
/** Character that starts and ends a glob character-class fragment. */
export const GLOB_RANGE_OPEN = '[';
export const GLOB_RANGE_CLOSE = ']';
+6
View File
@@ -79,6 +79,12 @@ export enum PdfViewMode {
PAGES = 'pages'
}
export enum ChatFormCommandAction {
PROMPT = 'prompt',
CWD = 'cwd',
MODEL = 'model'
}
export enum FileMentionEntryType {
FILE = 'file',
DIRECTORY = 'directory'
+1
View File
@@ -25,6 +25,7 @@ export {
MessageType,
PdfViewMode,
ReasoningFormat,
ChatFormCommandAction,
FileMentionEntryType
} from './chat.enums';
-1
View File
@@ -8,7 +8,6 @@ export enum KeyboardKey {
ARROW_DOWN = 'ArrowDown',
ARROW_LEFT = 'ArrowLeft',
ARROW_RIGHT = 'ArrowRight',
BACKSPACE = 'Backspace',
TAB = 'Tab',
B_LOWER = 'b',
D_LOWER = 'd',
@@ -0,0 +1,355 @@
import { getChatCommands, PROMPT_TRIGGER_PREFIX } from '$lib/constants';
import { ChatFormCommandAction, KeyboardKey } from '$lib/enums';
import type { ChatFormCommand } from '$lib/types';
import {
findCommandToken,
findMentionToken,
takeCommandDismissSnapshot,
takeMentionDismissSnapshot,
type CommandDismissSnapshot,
type MentionDismissSnapshot
} from '$lib/utils';
/** Dependencies injected as getters so the hook stays free of store circular imports. */
export interface UseChatFormPickersOptions {
getValue: () => string;
/** Also fires the form's onChange. */
setValue: (value: string) => void;
/** Undefined when unmounted. */
getCaretOffset: () => number | undefined;
setCaretOffset: (offset: number) => void;
focusInput: () => void;
/** Gates `/model`. */
getShowModelSelector: () => boolean;
/** Gates `/prompt`. */
hasPrompts: () => boolean;
/** Gates `/cwd`. */
hasCwdTools: () => boolean;
getCwd: () => string | null;
/** Mention search fallback scope. */
getServerHome: () => string | null;
openModelSelector: () => void;
/** Delegate a keydown to the mounted pickers component, if any. */
getPickersRef: () => { handleKeydown(event: KeyboardEvent): boolean } | undefined;
}
/**
* Chat-form picker state and the `/`+`@` routing that drives them.
* Owns open/query state, dismiss snapshots and slash-command dispatch;
* textarea/caret/attachment handling stays in the chat form.
*/
export function useChatFormPickers(opts: UseChatFormPickersOptions) {
let isCommandPickerOpen = $state(false);
let commandQuery = $state('');
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
let isMentionPickerOpen = $state(false);
let mentionQuery = $state('');
let isWorkingDirectoryPickerOpen = $state(false);
let workingDirectoryQuery = $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;
// Same dismissal contract for the `/`-command token.
let commandDismissedSnapshot: CommandDismissSnapshot | null = null;
// Fall back to the server home so the picker still finds matches
// before a cwd is set.
const mentionScopePath = $derived(opts.getCwd() ?? opts.getServerHome() ?? null);
const availableCommands = $derived(
getChatCommands({
showModelSelector: opts.getShowModelSelector(),
hasPrompts: opts.hasPrompts,
hasCwdTools: opts.hasCwdTools
})
);
// Dispatch a slash command picked from the list: consume the token and
// open the target picker, seeding its search with `args`. Runs only on
// explicit selection (Enter/click), so the buffer is never cleared
// mid-typing.
function dispatchCommand(command: ChatFormCommand, args: string) {
isCommandPickerOpen = false;
commandQuery = '';
switch (command.action) {
case ChatFormCommandAction.PROMPT:
isWorkingDirectoryPickerOpen = false;
opts.setValue('');
isPromptPickerOpen = true;
promptSearchQuery = args.trim();
break;
case ChatFormCommandAction.CWD: {
// Keep `/cwd <args>` in the input so the search field and the
// token stay two-way bound; normalize partial tokens (`/cw foo`).
const trimmed = args.trim();
const newValue = `/cwd ${trimmed}`;
if (opts.getValue() !== newValue) {
opts.setValue(newValue);
queueMicrotask(() => opts.setCaretOffset(newValue.length));
}
workingDirectoryQuery = trimmed;
isWorkingDirectoryPickerOpen = true;
break;
}
case ChatFormCommandAction.MODEL:
isWorkingDirectoryPickerOpen = false;
opts.setValue('');
opts.openModelSelector();
break;
}
}
function handleInput() {
const value = opts.getValue();
const cursor = opts.getCaretOffset() ?? value.length;
if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
isMentionPickerOpen = false;
mentionQuery = '';
isPromptPickerOpen = false;
promptSearchQuery = '';
const token = findCommandToken(value);
if (!token) {
isCommandPickerOpen = false;
commandQuery = '';
return;
}
// While the `/cwd` picker is open the token doubles as its search
// field: keep the two in sync instead of re-dispatching.
if (isWorkingDirectoryPickerOpen) {
isCommandPickerOpen = false;
commandQuery = '';
if (token.name === 'cwd') {
workingDirectoryQuery = token.args.trim();
} else {
isWorkingDirectoryPickerOpen = false;
workingDirectoryQuery = '';
}
return;
}
// Dismissed token stays literal until it changes.
const isDismissedSticky =
commandDismissedSnapshot !== null &&
commandDismissedSnapshot.name === token.name &&
commandDismissedSnapshot.args === token.args;
if (isDismissedSticky) {
isCommandPickerOpen = false;
commandQuery = '';
return;
}
// Commands dispatch only on explicit selection (Enter/click),
// never mid-typing: `/model is broken` is prose until the user
// picks the command from the list.
if (availableCommands.length > 0) {
isCommandPickerOpen = true;
commandQuery = token.name;
} else {
isCommandPickerOpen = false;
commandQuery = '';
}
return;
}
isCommandPickerOpen = false;
commandQuery = '';
if (commandDismissedSnapshot !== null) {
commandDismissedSnapshot = null;
}
if (isWorkingDirectoryPickerOpen) {
isWorkingDirectoryPickerOpen = false;
}
const token = findMentionToken(value, cursor);
if (token) {
// Dismissed token stays literal: don't reopen until it changes.
const isDismissedSticky =
mentionDismissedSnapshot !== null &&
mentionDismissedSnapshot.start === token.start &&
mentionDismissedSnapshot.query === token.query;
if (!isDismissedSticky) {
// Only search once a char follows `@`; a bare `@` is a no-op
// (otherwise the picker flashes an empty hint on re-type).
if (token.query.length > 0) {
mentionDismissedSnapshot = null;
isMentionPickerOpen = true;
mentionQuery = token.query;
isPromptPickerOpen = false;
promptSearchQuery = '';
return;
}
}
}
isPromptPickerOpen = false;
promptSearchQuery = '';
isMentionPickerOpen = false;
mentionQuery = '';
// Token gone or changed: reset the snapshot so a fresh `@` reopens.
if (mentionDismissedSnapshot !== null && !token) {
mentionDismissedSnapshot = null;
}
}
function handleKeydown(event: KeyboardEvent): boolean {
if (opts.getPickersRef()?.handleKeydown(event)) {
return true;
}
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
isPromptPickerOpen = false;
promptSearchQuery = '';
return true;
}
return false;
}
function handleCommandSelect(command: ChatFormCommand) {
// Dispatch on the live token so typed args seed the target picker.
const token = findCommandToken(opts.getValue());
dispatchCommand(command, token?.args ?? '');
}
// Picker dismissed: snapshot the live token so it stays literal until
// deleted or retyped.
function handleCommandPickerClose() {
if (isCommandPickerOpen) {
commandDismissedSnapshot = takeCommandDismissSnapshot(opts.getValue());
}
isCommandPickerOpen = false;
commandQuery = '';
// Target picker manages its own focus: don't yank it back to the input.
if (!isPromptPickerOpen && !isMentionPickerOpen && !isWorkingDirectoryPickerOpen) {
opts.focusInput();
}
}
// Same dismissal snapshot for the mention token.
function handleMentionPickerClose() {
if (isMentionPickerOpen) {
const cursor = opts.getCaretOffset() ?? opts.getValue().length;
mentionDismissedSnapshot = takeMentionDismissSnapshot(opts.getValue(), cursor);
}
isMentionPickerOpen = false;
mentionQuery = '';
opts.focusInput();
}
function handlePromptPickerClose() {
isPromptPickerOpen = false;
promptSearchQuery = '';
opts.focusInput();
}
function handleWorkingDirectoryOpen() {
workingDirectoryQuery = opts.getCwd() ?? '';
isWorkingDirectoryPickerOpen = true;
}
function handleWorkingDirectoryClose() {
isWorkingDirectoryPickerOpen = false;
workingDirectoryQuery = '';
opts.focusInput();
}
// Two-way bind the text after `/cwd ` and the picker search input; the
// reverse direction is handled by handleInput.
$effect(() => {
if (!isWorkingDirectoryPickerOpen) return;
const value = opts.getValue();
const token = findCommandToken(value);
if (!token || token.name !== 'cwd') return;
const newValue = `/cwd ${workingDirectoryQuery}`;
if (newValue === value) return;
opts.setValue(newValue);
queueMicrotask(() => opts.setCaretOffset(newValue.length));
});
return {
get isCommandPickerOpen() {
return isCommandPickerOpen;
},
set isCommandPickerOpen(v: boolean) {
isCommandPickerOpen = v;
},
get commandQuery() {
return commandQuery;
},
set commandQuery(v: string) {
commandQuery = v;
},
get isPromptPickerOpen() {
return isPromptPickerOpen;
},
set isPromptPickerOpen(v: boolean) {
isPromptPickerOpen = v;
},
get promptSearchQuery() {
return promptSearchQuery;
},
set promptSearchQuery(v: string) {
promptSearchQuery = v;
},
get isMentionPickerOpen() {
return isMentionPickerOpen;
},
set isMentionPickerOpen(v: boolean) {
isMentionPickerOpen = v;
},
get mentionQuery() {
return mentionQuery;
},
set mentionQuery(v: string) {
mentionQuery = v;
},
get isWorkingDirectoryPickerOpen() {
return isWorkingDirectoryPickerOpen;
},
set isWorkingDirectoryPickerOpen(v: boolean) {
isWorkingDirectoryPickerOpen = v;
},
get workingDirectoryQuery() {
return workingDirectoryQuery;
},
set workingDirectoryQuery(v: string) {
workingDirectoryQuery = v;
},
get availableCommands() {
return availableCommands;
},
get mentionScopePath() {
return mentionScopePath;
},
handleInput,
// True when a picker consumed the event, so the form skips submit.
handleKeydown,
dispatchCommand,
handleCommandSelect,
handleCommandPickerClose,
handleMentionPickerClose,
handlePromptPickerClose,
handleWorkingDirectoryOpen,
handleWorkingDirectoryClose,
openPromptPicker() {
isPromptPickerOpen = true;
},
closePromptPicker() {
isPromptPickerOpen = false;
promptSearchQuery = '';
}
};
}
export type UseChatFormPickersReturn = ReturnType<typeof useChatFormPickers>;
@@ -8,6 +8,7 @@ import {
singleModelName
} from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import type { ModelOption } from '$lib/types/models';
@@ -139,11 +140,9 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
handleOpenChange(false);
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>(
'[data-slot="chat-form"] textarea'
);
const input = document.querySelector<HTMLElement>(CHAT_INPUT_FOCUS_SELECTOR);
textarea?.focus({ preventScroll: true });
input?.focus({ preventScroll: true });
});
}
@@ -135,6 +135,30 @@ class SettingsStore {
...savedVal
};
// Migrate the legacy render keys into `renderContentAsRawText`
// (inverted semantics: the old keys opted INTO markdown). Any
// explicit raw-text preference wins when the legacy keys disagree.
const LEGACY_MARKDOWN_KEYS = ['renderUserContentAsMarkdown', 'renderThinkingAsMarkdown'];
const LEGACY_RAW_TEXT_KEY = 'renderUserContentAsRawText'; // this branch's intermediate key
const legacyKeys = [...LEGACY_MARKDOWN_KEYS, LEGACY_RAW_TEXT_KEY].filter(
(key) => key in savedVal
);
if (legacyKeys.length > 0) {
if (!(SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT in savedVal)) {
if (LEGACY_RAW_TEXT_KEY in savedVal) {
this.config[SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT] = savedVal[LEGACY_RAW_TEXT_KEY];
} else {
this.config[SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT] = LEGACY_MARKDOWN_KEYS.filter(
(key) => key in savedVal
).some((key) => savedVal[key] === false);
}
}
for (const key of legacyKeys) {
delete (this.config as Record<string, unknown>)[key];
}
this.saveConfig();
}
// Default sendOnEnter to false on mobile when the user has no saved preference
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
if (isMobile.current) {
+21
View File
@@ -27,6 +27,9 @@ class ToolsStore {
private _loading = $state(false);
private _error = $state<string | null>(null);
private _disabledTools = $state(new SvelteSet<string>());
// builtin tools that resolve their paths against the working directory,
// as declared by the server in its `/tools` listing
private _cwdAwareTools = $state(new SvelteSet<string>());
private _toolsEndpointUnreachable = $state(false);
private _serverHome = $state<string | null | undefined>(undefined);
@@ -476,6 +479,21 @@ class ToolsStore {
return this.getEnabledToolsForLLM().length > 0;
}
/**
* Check if a working directory is worth setting: at least one builtin tool
* that reads it is both served and left enabled by the user.
*/
get hasEnabledCwdTools(): boolean {
return this._builtinTools.some((def) => {
const name = def.function.name;
return (
this._cwdAwareTools.has(name) &&
!this._disabledTools.has(this.toolKey(ToolSource.BUILTIN, name))
);
});
}
async fetchBuiltinTools(): Promise<void> {
if (this._loading) return;
@@ -486,6 +504,9 @@ class ToolsStore {
try {
const toolInfos = await ToolsService.list();
this._builtinTools = toolInfos.map((info) => info.definition);
this._cwdAwareTools = new SvelteSet(
toolInfos.filter((info) => info.uses_cwd).map((info) => info.tool)
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
this._error = errorMessage;
+15 -1
View File
@@ -1,4 +1,4 @@
import type { ErrorDialogType, FileMentionEntryType } from '$lib/enums';
import type { ChatFormCommandAction, ErrorDialogType, FileMentionEntryType } from '$lib/enums';
import type { ApiChatCompletionToolCall } from './api';
import type { DatabaseMessage, DatabaseMessageExtra } from './database';
@@ -176,3 +176,17 @@ export interface FileMentionEntry {
name: string;
type: FileMentionEntryType;
}
/**
* A slash command surfaced by the `/` command picker. `disabled` marks a
* command whose backing capability is unavailable (e.g. `/prompt` when no
* MCP server exposes prompts): visible but greyed out and not selectable.
*/
export interface ChatFormCommand {
name: string;
description: string;
/** Extra search terms that should match this command in the picker. */
keywords?: string[];
action: ChatFormCommandAction;
disabled: boolean;
}
+2 -1
View File
@@ -54,7 +54,8 @@ export type {
LiveGenerationStats,
AttachmentDisplayItemsOptions,
FileProcessingResult,
FileMentionEntry
FileMentionEntry,
ChatFormCommand
} from './chat.d';
// Database types
+1
View File
@@ -292,6 +292,7 @@ export interface ServerBuiltinToolInfo {
permissions: {
write: boolean;
};
uses_cwd: boolean;
definition: OpenAIToolDefinition;
}
+50
View File
@@ -17,6 +17,56 @@ export interface IncompleteCodeBlock {
openingIndex: number;
}
// A fence line: up to 3 leading spaces (CommonMark), 3+ backticks, then
// whatever trails on the same line.
const FENCE_LINE_REGEX = /^ {0,3}(`{3,})(.*)$/;
/**
* Splits text glued to a closing code fence onto its own line:
*
* ```ts
* let foo = 'bar';
* ```create this file on ...
*
* A closing fence with trailing text is not a fence to the markdown
* parser, so the block would swallow the text as code. The chat form
* normally keeps the fence on its own line, but older messages and
* hand-pasted content can carry the glued form.
*
* Only trailing text containing whitespace is split: a single word
* after the backticks inside a fenced block is more likely nested
* markdown (a ```python example inside a ```md block) than glued prose.
*/
export function splitGluedClosingCodeFences(markdown: string): string {
if (!markdown.includes('```')) return markdown;
const lines = markdown.split(NEWLINE);
let inside = false;
let changed = false;
for (let i = 0; i < lines.length; i++) {
const match = FENCE_LINE_REGEX.exec(lines[i]);
if (!match) continue;
if (!inside) {
inside = true;
continue;
}
inside = false;
const trailing = match[2];
if (trailing.includes('`') || !/\s/.test(trailing)) continue;
lines[i] = lines[i].slice(0, lines[i].length - trailing.length);
lines.splice(i + 1, 0, trailing.trim());
i++;
changed = true;
}
return changed ? lines.join(NEWLINE) : markdown;
}
/**
* Strips empty lines (whitespace-only) from the start and end of code.
*
+31
View File
@@ -0,0 +1,31 @@
/**
* Slash-command token detection for the chat form. Valid only at offset 0.
*/
export function findCommandToken(
value: string
): { name: string; args: string; end: number } | null {
if (!value.startsWith('/')) return null;
const rest = value.slice(1);
const spaceIdx = rest.search(/\s/);
const name = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const args = spaceIdx === -1 ? '' : rest.slice(spaceIdx + 1);
return { name, args, end: value.length };
}
/**
* Stable signature of a slash-command token for use as a "dismissed"
* marker: while the picker is closed and this exact token is still intact,
* the picker does not re-open on in-token edits.
*/
export interface CommandDismissSnapshot {
name: string;
args: string;
}
export function takeCommandDismissSnapshot(value: string): CommandDismissSnapshot | null {
const token = findCommandToken(value);
if (!token) return null;
return { name: token.name, args: token.args };
}
@@ -0,0 +1,890 @@
/**
* Maps between the chat-form contenteditable's markdown source and the
* badge/code/text token stream the DOM is built from. A badge is one
* opaque source contribution (`[name](file://path)`); its own subtree
* is never walked, and the caret cannot land inside it, so offsets
* resolve to the nearest badge edge. Code spans (`<code data-code-token>`)
* are EDITABLE, unlike badges: they carry the full source segment
* (backtick fences included) as their text, so their textContent
* serializes verbatim and source offsets map 1:1 to text offsets.
*
* The tokenizer emits a flat DOM (text nodes + badges + code spans),
* but browsers restructure it on Enter (`<div>` line wrappers, `<br>`
* shapes). Serialization folds those back into `\n` so the source
* never diverges from what is on screen; both offset mappers
* understand the same shapes.
*
* The newline separating a fenced block from adjacent content is a
* SOURCE-level concept, never stored in the DOM: the block is
* display:block, so a leading `\n` in the following text node would
* render as a phantom empty line. Serialization synthesizes exactly
* one `\n` at every block boundary and `buildFragment` strips it from
* text tokens. A text node's own leading/trailing `\n` next to a
* block is an ADDITIONAL blank line.
*/
import {
decodeFileLinkPath,
fileMentionLinkRe,
getMentionBadgeIconPaths,
getMentionBadgeLabel
} from './mention-badge';
import {
MENTION_BADGE_CLASSNAME,
MENTION_BADGE_ICON_CLASSNAME,
MENTION_BADGE_SVG_ATTRIBUTES,
SETTINGS_KEYS
} from '$lib/constants';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
export type ContentToken =
| { kind: 'text'; text: string }
| { kind: 'badge'; name: string; path: string }
| { kind: 'inlineCode'; text: string }
| { kind: 'codeBlock'; text: string };
// Block wrappers browsers insert for newlines; each folds back into a
// single `\n` during serialization.
const BLOCK_TAG_NAMES = new Set(['DIV', 'P']);
// `file://` is required so plain URLs stay as text; `)` terminates only
// when not followed by whitespace or `[` (adjacent badges keep working).
const MENTION_BADGE_RE = fileMentionLinkRe('g');
function badgeSourceLength(name: string, path: string): number {
if (!name || !path) return 0;
return `[${name}](file://${path})`.length;
}
/**
* Recognize complete code spans. Fenced blocks (triple backticks,
* optional language, possibly multiline) take priority over inline
* spans (single backticks, single line, non-empty). Only CLOSED
* spans match: an unclosed fence stays plain text until the closing
* backticks land. The match includes the fences so the token's
* source length equals its rendered text length.
*/
const CODE_SPAN_RE = /(```[\s\S]*?```)|(`[^`\n]+`)/g;
/**
* Cheap gate check for `ChatForm`: does the buffer contain a
* complete code span (inline or fenced)? Used to promote the plain
* textarea to the contenteditable renderer.
*/
export function containsCodeSpan(value: string): boolean {
CODE_SPAN_RE.lastIndex = 0;
return CODE_SPAN_RE.test(value);
}
const CODE_FENCE_RE = /```/g;
/**
* Is `offset` inside a fenced code block region? Toggle-based: an
* odd number of ``` fences before the offset means the position
* sits in block content. Unlike `containsCodeSpan` this also
* counts the still-OPEN fence while the user is typing a block
* (no closing ``` yet), so Enter can add a line instead of
* submitting the message.
*/
export function isOffsetInCodeBlock(source: string, offset: number): boolean {
let inside = false;
CODE_FENCE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = CODE_FENCE_RE.exec(source)) !== null) {
if (match.index + match[0].length > offset) break;
inside = !inside;
}
return inside;
}
/**
* Tokenize a markdown source value into the segments the
* contenteditable will render. Code spans are carved out first
* (their content is literal - a `file://` link inside backticks
* must NOT render as a badge), then plain text and badges
* interleave in the remaining gaps. Any whitespace after a badge
* stays in a plain text token so the round trip is byte-exact.
*/
export function tokenizeContent(input: string): ContentToken[] {
const tokens: ContentToken[] = [];
let cursor = 0;
CODE_SPAN_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = CODE_SPAN_RE.exec(input)) !== null) {
const start = match.index;
if (start > cursor) {
pushTextAndBadgeTokens(input.slice(cursor, start), tokens);
}
tokens.push(
match[1] !== undefined
? { kind: 'codeBlock', text: match[1] }
: { kind: 'inlineCode', text: match[2] }
);
cursor = start + match[0].length;
}
if (cursor < input.length) {
pushTextAndBadgeTokens(input.slice(cursor), tokens);
}
return tokens;
}
/**
* Tokenize a code-free segment into text and badge tokens.
*/
function pushTextAndBadgeTokens(input: string, tokens: ContentToken[]) {
let cursor = 0;
MENTION_BADGE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = MENTION_BADGE_RE.exec(input)) !== null) {
const [whole, name, path] = match;
const start = match.index;
if (start > cursor) {
tokens.push({ kind: 'text', text: input.slice(cursor, start) });
}
tokens.push({ kind: 'badge', name, path });
cursor = start + whole.length;
}
if (cursor < input.length) {
tokens.push({ kind: 'text', text: input.slice(cursor) });
}
}
function isCodeBlockElement(node: Node | null): node is HTMLElement {
return node instanceof HTMLElement && node.dataset.codeToken === 'block';
}
/**
* Serialize a contenteditable subtree back to source. `<br>` and block
* wrappers the browser inserted for newlines fold back into `\n` (a
* trailing `<br>` is the browser's caret placeholder, not a newline);
* any other element is transparent. Code spans serialize their
* textContent verbatim (fences included). One separator `\n` is
* synthesized at every fenced-block boundary (the DOM never stores
* it), and a `<br>` adjacent to a code block is an escape hatch, not
* a newline.
*/
export function serializeContent(root: HTMLElement): string {
let out = '';
let pendingBlockBoundary = false;
const walk = (parent: Node) => {
let first = true; // no source-contributing sibling seen yet
for (const child of Array.from(parent.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent ?? '';
if (text.length > 0) {
if (pendingBlockBoundary) {
out += '\n';
pendingBlockBoundary = false;
}
out += text;
first = false;
}
continue;
}
if (child.nodeType !== Node.ELEMENT_NODE) continue;
const el = child as HTMLElement;
if (el.dataset.mentionBadge === 'true') {
const name = el.dataset.mentionName ?? '';
const path = el.dataset.mentionPath ?? '';
if (name && path) {
if (pendingBlockBoundary) {
out += '\n';
pendingBlockBoundary = false;
}
out += `[${name}](file://${path})`;
first = false;
}
continue;
}
if (el.dataset.codeToken !== undefined) {
const isBlock = el.dataset.codeToken === 'block';
if (isBlock && (pendingBlockBoundary || !first)) out += '\n';
pendingBlockBoundary = false;
walk(el);
first = false;
if (isBlock) pendingBlockBoundary = true;
continue;
}
if (el.tagName === 'BR') {
const isHatch =
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
if (!isHatch && el.nextSibling) {
if (pendingBlockBoundary) {
out += '\n';
pendingBlockBoundary = false;
}
out += '\n';
first = false;
}
continue;
}
if (BLOCK_TAG_NAMES.has(el.tagName)) {
if (pendingBlockBoundary || !first) out += '\n';
pendingBlockBoundary = false;
walk(el);
first = false;
continue;
}
walk(el);
if (pendingBlockBoundary) first = false;
}
};
walk(root);
return out;
}
/**
* Compare the live DOM's non-text structure against a token stream.
* Only element contributions are compared (badges by name/path, code
* spans by kind and source segment): text nodes are owned by the
* browser between rebuilds, so their split/merge state is irrelevant.
* A mismatch means token boundaries shifted (a code span was just
* completed or broken) and the DOM needs a rebuild to restyle.
*/
export function domMatchesTokens(root: HTMLElement, tokens: ContentToken[]): boolean {
const expected = tokens.filter((token) => token.kind !== 'text');
let index = 0;
const walk = (parent: Node): boolean => {
for (const child of Array.from(parent.childNodes)) {
if (child.nodeType !== Node.ELEMENT_NODE) continue;
const el = child as HTMLElement;
const isBadge = el.dataset.mentionBadge === 'true';
const isCode = el.dataset.codeToken !== undefined;
if (!isBadge && !isCode) {
if (!walk(el)) return false;
continue;
}
const token = expected[index++];
if (!token) return false;
if (isBadge) {
if (token.kind !== 'badge') return false;
if (token.name !== (el.dataset.mentionName ?? '')) return false;
if (token.path !== (el.dataset.mentionPath ?? '')) return false;
continue;
}
const codeKind = el.dataset.codeToken === 'block' ? 'codeBlock' : 'inlineCode';
if (token.kind !== codeKind) return false;
if (
(token.kind === 'inlineCode' || token.kind === 'codeBlock') &&
token.text !== (el.textContent ?? '')
) {
return false;
}
}
return true;
};
return walk(root) && index === expected.length;
}
/**
* Plain-text offset of a `Range` in the root; null range (selection
* lost) falls back to buffer length. Walked against the live DOM (not
* a clone) so a `<br>` keeps its trailing/not-trailing context. Code
* spans count their full textContent (fences included) and the caret
* may land inside them; synthesized block boundaries count one `\n`
* once the caret is past them.
*/
export function rangeToTextOffset(root: HTMLElement, range: Range | null): number {
if (!range) return serializeContent(root).length;
// A point is at/before the caret iff it falls inside [root start, caret].
const pre = range.cloneRange();
pre.selectNodeContents(root);
pre.setEnd(range.endContainer, range.endOffset);
const atOrBeforeCaret = (node: Node, offset: number) => pre.comparePoint(node, offset) !== 1;
let total = 0;
let done = false;
// DOM position of a code block's synthesized after-boundary, set
// when walking past a block and consumed by the next contributing
// sibling (counts one `\n` once the caret is past it).
let pendingPoint: { node: Node; index: number } | null = null;
const walk = (parent: Node) => {
let first = true;
for (const child of Array.from(parent.childNodes)) {
if (done) return;
if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent ?? '';
if (text.length === 0) continue;
if (pendingPoint) {
const { node, index } = pendingPoint;
pendingPoint = null;
if (!atOrBeforeCaret(node, index)) {
done = true;
return;
}
total += 1;
}
if (!atOrBeforeCaret(child, 0)) {
done = true;
return;
}
if (range.endContainer === child) {
total += range.endOffset;
done = true;
return;
}
total += text.length;
first = false;
continue;
}
if (child.nodeType !== Node.ELEMENT_NODE) continue;
const el = child as HTMLElement;
const parentNode = el.parentNode as Node;
const elIndex = Array.prototype.indexOf.call(parentNode.childNodes, el);
if (pendingPoint) {
const { node, index } = pendingPoint;
pendingPoint = null;
if (!atOrBeforeCaret(node, index)) {
done = true;
return;
}
total += 1;
}
if (el.dataset.mentionBadge === 'true') {
const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
if (len === 0) continue;
if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
done = true;
return;
}
total += len;
first = false;
continue;
}
if (el.dataset.codeToken !== undefined) {
const isBlock = el.dataset.codeToken === 'block';
if (isBlock && !first) {
if (!atOrBeforeCaret(el, 0)) {
done = true;
return;
}
total += 1;
}
walk(el);
first = false;
if (isBlock) pendingPoint = { node: parentNode, index: elIndex + 1 };
continue;
}
if (el.tagName === 'BR') {
const isHatch =
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
if (isHatch || !el.nextSibling) continue;
if (!atOrBeforeCaret(parentNode, elIndex + 1)) {
done = true;
return;
}
total += 1;
first = false;
continue;
}
if (BLOCK_TAG_NAMES.has(el.tagName)) {
if (!first) {
if (!atOrBeforeCaret(el, 0)) {
done = true;
return;
}
total += 1;
}
walk(el);
first = false;
continue;
}
const before = total;
walk(el);
if (total > before) first = false;
}
};
walk(root);
return total;
}
/**
* Materialize a token stream into a DOM subtree: text nodes for text
* tokens, `<span data-mention-badge="true">` elements for badges,
* `<code data-code-token>` elements for code spans. The badge's class
* string + inline SVG are shared with the rehype plugin via
* `$lib/constants/mention-badge`.
*/
export function buildFragment(tokens: ContentToken[]): DocumentFragment {
const fragment = document.createDocumentFragment();
for (let index = 0; index < tokens.length; index++) {
const token = tokens[index];
if (token.kind === 'text') {
let text = token.text;
// The separator \n at a fenced-block boundary is synthesized
// at serialization time; keeping it in the DOM would render a
// phantom empty line next to the block.
if (tokens[index - 1]?.kind === 'codeBlock' && text.startsWith('\n')) {
text = text.slice(1);
}
if (tokens[index + 1]?.kind === 'codeBlock' && text.endsWith('\n')) {
text = text.slice(0, -1);
}
if (text.length === 0) continue;
fragment.appendChild(document.createTextNode(text));
continue;
}
if (token.kind === 'inlineCode' || token.kind === 'codeBlock') {
const code = document.createElement('code');
code.dataset.codeToken = token.kind === 'codeBlock' ? 'block' : 'inline';
code.textContent = token.text;
fragment.appendChild(code);
continue;
}
// A leading badge gets an empty text node prepended: without a real
// text position at the buffer start, the spot before the badge is
// unreachable via keyboard (ArrowLeft/Home).
if (!fragment.lastChild) {
fragment.appendChild(document.createTextNode(''));
}
const badge = document.createElement('span');
badge.dataset.mentionBadge = 'true';
badge.dataset.mentionName = token.name;
badge.dataset.mentionPath = token.path;
badge.title = decodeFileLinkPath(token.path);
badge.className = MENTION_BADGE_CLASSNAME;
badge.contentEditable = 'false';
const svg = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'svg');
for (const [attr, value] of Object.entries(MENTION_BADGE_SVG_ATTRIBUTES)) {
svg.setAttribute(attr, value);
}
for (const cls of MENTION_BADGE_ICON_CLASSNAME.split(/\s+/).filter(Boolean)) {
svg.classList.add(cls);
}
for (const d of getMentionBadgeIconPaths(token.path)) {
const path = document.createElementNS(MENTION_BADGE_SVG_ATTRIBUTES['xmlns'], 'path');
path.setAttribute('d', d);
svg.appendChild(path);
}
const label = document.createElement('span');
label.classList.add('shrink-0', 'truncate');
label.textContent = getMentionBadgeLabel(
token.name,
decodeFileLinkPath(token.path),
settingsStore.getConfig(SETTINGS_KEYS.SHOW_FULL_PATH_IN_MENTIONS),
toolsStore.serverHome
);
badge.appendChild(svg);
badge.appendChild(label);
fragment.appendChild(badge);
}
return fragment;
}
// A sibling provides a reachable caret line when it is an element
// (badge, another block, an existing hatch) or a non-empty text node.
function hasLineBeside(node: Node | null): boolean {
if (!node) return false;
if (node.nodeType === Node.ELEMENT_NODE) return true;
return (node.textContent ?? '') !== '';
}
/**
* A code block at the END of the buffer needs an editable line after
* it: without one the caret cannot leave the block with
* ArrowDown/ArrowRight. A trailing `<br>` provides that line while
* staying transparent to serialization (skipped as a hatch), and is
* removed again once real content takes its place.
*
* No hatch is added BEFORE a leading block: the empty line above it
* is transient and managed by the component (created when the caret
* arrows onto it, removed when the caret leaves). A transient
* leading hatch found here is kept; the browser's lone placeholder
* `<br>` in an empty root is left untouched.
*/
export function syncCodeBlockHatches(root: HTMLElement) {
for (const child of Array.from(root.childNodes)) {
if (child.nodeName !== 'BR') continue;
const isPlaceholder = root.childNodes.length === 1;
const isLeadingHatch = !child.previousSibling && isCodeBlockElement(child.nextSibling);
const isTrailingHatch = !child.nextSibling && isCodeBlockElement(child.previousSibling);
// A hatch goes stale once real content takes over its line:
// content before a leading hatch, content after a trailing one,
// or a text node after the block already providing the line.
// A `<br>` with no code block around is a real newline (browser
// Shift+Enter shape) and stays.
let prevElement = child.previousSibling;
while (prevElement && prevElement.nodeType !== Node.ELEMENT_NODE) {
prevElement = prevElement.previousSibling;
}
const nearBlock =
isCodeBlockElement(child.nextSibling) ||
isCodeBlockElement(child.previousSibling) ||
isCodeBlockElement(prevElement);
if (!isPlaceholder && !isLeadingHatch && !isTrailingHatch && nearBlock) {
child.remove();
}
}
for (const child of Array.from(root.childNodes)) {
if (!isCodeBlockElement(child)) continue;
if (!hasLineBeside(child.nextSibling)) {
child.after(document.createElement('br'));
}
}
}
/**
* Strip the separator and artificial newlines from an all-newline text
* node directly after a fenced block. Chromium's line break at the
* buffer end inserts an extra artificial `\n` so the new line has
* height, and the first `\n` after a block doubles as the fence's
* separator line (synthesized at serialization time). Removing both
* makes Shift+Enter after a block land the caret on the line directly
* below the block, like a plain textarea would.
*
* Only all-newline text nodes are touched: a node with real content
* carries intentional blank lines and is left alone. Returns true when
* the DOM changed.
*/
export function stripBlockBoundaryLineBreaks(root: HTMLElement): boolean {
let changed = false;
for (const child of Array.from(root.childNodes)) {
if (child.nodeType !== Node.TEXT_NODE) continue;
if (!isCodeBlockElement(child.previousSibling)) continue;
let text = child.textContent ?? '';
if (!/^\n{2,}$/.test(text)) continue;
text = text.slice(1);
const atBufferEnd = !child.nextSibling || child.nextSibling.nodeName === 'BR';
if (atBufferEnd) {
text = text.slice(0, -1);
}
child.textContent = text;
changed = true;
}
return changed;
}
const WORD_CHAR_RE = /[\p{L}\p{N}_]/u;
/**
* Word-jump target (Option+Arrow / Ctrl+Arrow) in source offsets, or null
* when the jump crosses no badge and native word movement should handle
* it. Badge spans are masked to word characters, so a badge counts as
* exactly one word.
*/
export function badgeAwareWordJump(
source: string,
offset: number,
direction: 'forward' | 'backward'
): number | null {
let masked = '';
const badgeSpans: Array<[number, number]> = [];
for (const token of tokenizeContent(source)) {
const len =
token.kind === 'badge' ? badgeSourceLength(token.name, token.path) : token.text.length;
if (token.kind === 'badge') badgeSpans.push([masked.length, masked.length + len]);
masked += token.kind === 'badge' ? 'a'.repeat(len) : token.text;
}
if (badgeSpans.length === 0) return null;
const isWord = (index: number) => WORD_CHAR_RE.test(masked[index]);
const spanStartingAt = (index: number) => badgeSpans.find(([start]) => start === index);
const spanEndingAt = (index: number) => badgeSpans.find(([, end]) => end === index);
const n = masked.length;
let i = offset;
if (direction === 'forward') {
// Entering a badge completes the word phase at the badge's end edge.
if (!(i < n && isWord(i))) {
while (i < n && !isWord(i)) i++;
}
while (i < n && isWord(i)) {
const span = spanStartingAt(i);
if (span) {
i = span[1];
break;
}
i++;
}
} else {
if (!(i > 0 && isWord(i - 1))) {
while (i > 0 && !isWord(i - 1)) i--;
}
while (i > 0 && isWord(i - 1)) {
const span = spanEndingAt(i);
if (span) {
i = span[0];
break;
}
i--;
}
}
if (i === offset) return null;
const lo = Math.min(offset, i);
const hi = Math.max(offset, i);
return badgeSpans.some(([start, end]) => start < hi && end > lo) ? i : null;
}
/**
* 0 when `caret` sits exactly at a leading badge's end edge, null
* otherwise. Plain ArrowLeft there has no native previous position, so
* the host snaps the caret to the buffer start manually.
*/
export function leadingBadgeEdgeOffset(source: string, caret: number): number | null {
const [first] = tokenizeContent(source);
if (!first || first.kind !== 'badge') return null;
return caret === badgeSourceLength(first.name, first.path) ? 0 : null;
}
/**
* Translate a plain-text offset into a degenerate `Range` at that
* position in the DOM; out-of-range offsets clamp to buffer end (before
* a trailing escape hatch, not after it). Zero offset lands BEFORE a
* badge or code span, and an offset exactly at a code span's end lands
* AFTER it, so typing at a code span's edge extends the surrounding
* text. Interior code-span offsets land in the element's text.
* Understands the same block/`<br>` newline shapes as
* `serializeContent`.
*/
export function textOffsetToRange(root: HTMLElement, offset: number): Range {
const range = document.createRange();
let remaining = offset;
let landed = false;
let pendingBlockBoundary = false;
const land = (node: Node, nodeOffset: number) => {
range.setStart(node, nodeOffset);
range.setEnd(node, nodeOffset);
landed = true;
};
const walk = (parent: Node) => {
let first = true;
for (const child of Array.from(parent.childNodes)) {
if (landed) return;
if (child.nodeType === Node.TEXT_NODE) {
const text = child.textContent ?? '';
if (text.length === 0) continue;
if (pendingBlockBoundary) {
// The synthesized separator maps to the near edge of the
// content that follows the block.
pendingBlockBoundary = false;
if (remaining === 0) {
land(child, 0);
return;
}
remaining -= 1;
}
if (remaining <= text.length) {
land(child, remaining);
return;
}
remaining -= text.length;
first = false;
continue;
}
if (child.nodeType !== Node.ELEMENT_NODE) continue;
const el = child as HTMLElement;
if (el.dataset.mentionBadge === 'true') {
const len = badgeSourceLength(el.dataset.mentionName ?? '', el.dataset.mentionPath ?? '');
if (len === 0) continue;
if (pendingBlockBoundary) {
pendingBlockBoundary = false;
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
return;
}
remaining -= 1;
}
if (remaining <= len) {
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
} else {
range.setStartAfter(el);
range.setEndAfter(el);
}
landed = true;
return;
}
remaining -= len;
first = false;
continue;
}
if (el.dataset.codeToken !== undefined) {
const isBlock = el.dataset.codeToken === 'block';
if (isBlock && (pendingBlockBoundary || !first)) {
pendingBlockBoundary = false;
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
return;
}
remaining -= 1;
}
const len = (el.textContent ?? '').length;
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
return;
}
if (remaining === len) {
range.setStartAfter(el);
range.setEndAfter(el);
landed = true;
return;
}
if (remaining < len) {
walk(el);
return;
}
remaining -= len;
if (isBlock) remaining -= 1;
first = false;
continue;
}
if (el.tagName === 'BR') {
const isHatch =
isCodeBlockElement(el.previousSibling) || isCodeBlockElement(el.nextSibling);
if (isHatch) {
// Escape hatch: no source length; offset 0 lands before it
// so text typed there takes its place.
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
}
continue;
}
if (!el.nextSibling) continue;
if (pendingBlockBoundary) {
pendingBlockBoundary = false;
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
return;
}
remaining -= 1;
}
if (remaining === 0) {
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
return;
}
remaining -= 1;
first = false;
continue;
}
if (BLOCK_TAG_NAMES.has(el.tagName)) {
if (pendingBlockBoundary || !first) {
pendingBlockBoundary = false;
if (remaining === 0) {
// The boundary newline belongs to the previous line.
range.setStartBefore(el);
range.setEndBefore(el);
landed = true;
return;
}
remaining -= 1;
}
walk(el);
first = false;
continue;
}
const before = remaining;
walk(el);
if (remaining < before) first = false;
}
};
walk(root);
if (!landed) {
const last = root.lastChild;
if (last && last.nodeName === 'BR') {
range.setStartBefore(last);
range.setEndBefore(last);
} else {
range.selectNodeContents(root);
range.collapse(false);
}
}
return range;
}
+32 -4
View File
@@ -33,6 +33,7 @@ export {
export {
highlightCode,
detectIncompleteCodeBlock,
splitGluedClosingCodeFences,
trimCodePadding,
type IncompleteCodeBlock
} from './code';
@@ -198,9 +199,37 @@ export {
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
// Slash-command token detection (for the `/`-triggered command picker)
export {
findCommandToken,
takeCommandDismissSnapshot,
type CommandDismissSnapshot
} from './command-token';
// Tokenization for the chat-form contenteditable (mention links + code spans <-> chip DOM)
export {
tokenizeContent,
containsCodeSpan,
isOffsetInCodeBlock,
domMatchesTokens,
syncCodeBlockHatches,
stripBlockBoundaryLineBreaks,
serializeContent,
buildFragment,
rangeToTextOffset,
textOffsetToRange,
badgeAwareWordJump,
leadingBadgeEdgeOffset,
type ContentToken
} from './contenteditable-tokenizer';
// Source-space undo/redo history for the chat-form contenteditable
export { SourceHistory, type SourceHistoryEntry } from './source-history';
// Mention-badge visual contract (used by the contenteditable / rehype
// DOM paths that build the same chip without a Svelte mount)
export {
containsFileMentionLink,
fileMentionLinkRe,
encodeFileLinkPath,
decodeFileLinkPath,
@@ -211,8 +240,7 @@ export {
MENTION_BADGE_FOLDER_ICON_PATHS,
getMentionBadgeIconPaths,
getMentionBadgeLabel,
buildMentionInsertion,
mentionLinkEndingAt
buildMentionInsertion
} from './mention-badge';
// Agentic content utilities (structured section derivation)
+4 -19
View File
@@ -23,6 +23,10 @@ export function fileMentionLinkRe(flags = ''): RegExp {
return new RegExp(FILE_MENTION_LINK_SOURCE, flags);
}
export function containsFileMentionLink(value: string): boolean {
return fileMentionLinkRe().test(value);
}
// Escape each path segment for a markdown link destination (spaces/parens
// break CommonMark); keeps the trailing slash that marks a directory.
export function encodeFileLinkPath(path: string): string {
@@ -60,25 +64,6 @@ export function getMentionBadgeLabel(
return abbreviateHome(decoded, home);
}
/**
* Extent of the mention link ending exactly at `caret`, so Backspace there
* deletes the whole `[name](file://...)` token in one keystroke instead of
* unraveling it character by character. Null when no link ends at `caret`.
*/
export function mentionLinkEndingAt(
value: string,
caret: number
): { start: number; end: number } | null {
const re = fileMentionLinkRe('g');
let match: RegExpExecArray | null;
while ((match = re.exec(value)) !== null) {
const end = match.index + match[0].length;
if (end === caret) return { start: match.index, end };
if (end > caret) break;
}
return null;
}
/**
* Build the markdown link that replaces a mention token. Entry `path` is
* already rooted, so `file://` + `/abs` yields the canonical `file:///`.
+5 -1
View File
@@ -19,7 +19,9 @@ export function modelLoadStageLabel(stage: ApiModelLoadStage): string {
export function modelLoadFraction(progress: ModelLoadProgress | null): number {
if (!progress) return 0;
const { stages, current, value } = progress;
// The server may emit a progress event before the stage plan is known, so
// `stages` can be absent. Fall back to the raw value in that case.
const { stages = [], current, value } = progress;
const tailCount = Math.max(stages.length - 1, 0);
const textCeiling = 1 - tailCount * MODEL_LOAD_TAIL_SHARE;
const idx = stages.indexOf(current);
@@ -39,5 +41,7 @@ export function modelLoadProgressText(progress: ModelLoadProgress | null): strin
if (!progress) return null;
const label = modelLoadStageLabel(progress.current);
if (!label) return null;
return `${label} ${Math.round(modelLoadFraction(progress) * 100)}%`;
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Source-space undo/redo history for the chat-form contenteditable, whose
* imperative DOM rebuilds destroy the browser's native undo stack.
* Entries record the state BEFORE an edit; edits within `groupWindowMs`
* extend the open group so a typing burst undoes as a unit, while
* structural edits (paste, mention insert, clear) pass `newGroup`.
*/
export interface SourceHistoryEntry {
value: string;
caret: number;
}
export class SourceHistory {
private undoStack: SourceHistoryEntry[] = [];
private redoStack: SourceHistoryEntry[] = [];
private lastPush = 0;
constructor(
private limit = 100,
private groupWindowMs = 800
) {}
push(entry: SourceHistoryEntry, now: number, newGroup = false): void {
if (newGroup || now - this.lastPush >= this.groupWindowMs || this.undoStack.length === 0) {
this.undoStack.push(entry);
if (this.undoStack.length > this.limit) this.undoStack.shift();
}
this.lastPush = now;
this.redoStack = [];
}
undo(current: SourceHistoryEntry): SourceHistoryEntry | null {
const entry = this.undoStack.pop();
if (!entry) return null;
this.redoStack.push(current);
this.lastPush = 0; // the next edit after an undo starts a new group
return entry;
}
redo(current: SourceHistoryEntry): SourceHistoryEntry | null {
const entry = this.redoStack.pop();
if (!entry) return null;
this.undoStack.push(current);
this.lastPush = 0;
return entry;
}
}
@@ -0,0 +1,156 @@
// Guards the newline contract of the chat-form contenteditable: browsers
// restructure the flat DOM on Enter (`<div>` wrappers, `<br>` shapes) and
// serialization must fold those back into `\n` so the emitted value never
// diverges from what is on screen.
import { describe, it, expect } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { tick } from 'svelte';
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
const SOURCE = 'see [docs](file:///a/b) here';
function editableIn(container: HTMLElement): HTMLElement {
const el = container.querySelector('[role="textbox"]');
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
return el;
}
function fireInput(root: HTMLElement) {
root.dispatchEvent(new InputEvent('input', { bubbles: true }));
}
function setCaret(node: Node, offset: number) {
const range = document.createRange();
range.setStart(node, offset);
range.setEnd(node, offset);
const selection = window.getSelection();
if (!selection) throw new Error('no selection');
selection.removeAllRanges();
selection.addRange(range);
}
describe('ChatFormContenteditable browser newline shapes', () => {
it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
await tick();
const root = editableIn(screen.container);
const div = document.createElement('div');
div.textContent = 'second line';
root.appendChild(div);
fireInput(root);
await tick();
expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
});
it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
await tick();
const root = editableIn(screen.container);
const first = document.createElement('div');
while (root.firstChild) first.appendChild(root.firstChild);
const second = document.createElement('div');
second.textContent = 'second line';
root.appendChild(first);
root.appendChild(second);
fireInput(root);
await tick();
expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
});
it('serializes a <br> as a newline', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'here' });
await tick();
const root = editableIn(screen.container);
root.appendChild(document.createElement('br'));
root.appendChild(document.createTextNode('second line'));
fireInput(root);
await tick();
expect(screen.component.getValue()).toBe('here\nsecond line');
});
it('ignores a trailing <br> (browser caret placeholder)', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
await tick();
const root = editableIn(screen.container);
root.appendChild(document.createElement('br'));
fireInput(root);
await tick();
expect(screen.component.getValue()).toBe('abc');
});
it('serializes one newline per empty-line <div><br></div>', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
await tick();
const root = editableIn(screen.container);
for (let i = 0; i < 2; i++) {
const div = document.createElement('div');
div.appendChild(document.createElement('br'));
root.appendChild(div);
}
fireInput(root);
await tick();
expect(screen.component.getValue()).toBe('abc\n\n');
});
it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
await tick();
const root = editableIn(screen.container);
const div = document.createElement('div');
div.appendChild(document.createElement('br'));
root.replaceChildren(div);
fireInput(root);
await tick();
expect(screen.component.getValue()).toBe('');
expect(root.dataset.empty).toBe('true');
});
it('maps the caret across block boundaries in both directions', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc\ndef' });
await tick();
// Rebuild into the Chromium block shape; the source is unchanged,
// so no re-render fires.
const root = editableIn(screen.container);
const div = document.createElement('div');
div.textContent = 'def';
root.replaceChildren(document.createTextNode('abc'), div);
fireInput(root);
await tick();
expect(screen.component.getValue()).toBe('abc\ndef');
const divText = div.firstChild;
if (!divText) throw new Error('div text missing');
setCaret(divText, 2);
expect(screen.component.getCaretOffset()).toBe(6);
screen.component.setCaretOffset(6);
const selection = window.getSelection();
expect(selection?.anchorNode).toBe(divText);
expect(selection?.anchorOffset).toBe(2);
// The boundary newline itself: offset 3 is the end of "abc", offset
// 4 the start of the "def" line.
screen.component.setCaretOffset(4);
expect(window.getSelection()?.anchorNode).toBe(divText);
expect(window.getSelection()?.anchorOffset).toBe(0);
screen.component.setCaretOffset(3);
expect(window.getSelection()?.anchorNode).toBe(root.firstChild);
expect(window.getSelection()?.anchorOffset).toBe(3);
});
});
@@ -0,0 +1,142 @@
// Guards the editing-key contract of the chat-form contenteditable:
// undo/redo is replayed from source snapshots (the token rebuilds destroy
// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
// keyboard trap), matching the plain textarea.
import { describe, it, expect } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { tick } from 'svelte';
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.svelte';
const SOURCE = 'see [docs](file:///a/b)';
function editableIn(container: HTMLElement): HTMLElement {
const el = container.querySelector('[role="textbox"]');
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
return el;
}
function type(root: HTMLElement, text: string, inputType = 'insertText') {
root.appendChild(document.createTextNode(text));
root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType }));
}
function keydown(root: HTMLElement, init: KeyboardEventInit) {
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
root.dispatchEvent(event);
return event;
}
describe('ChatFormContenteditable undo/redo', () => {
it('undoes and redoes an edit across a badge-containing buffer', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
await tick();
const root = editableIn(screen.container);
type(root, ' more');
await tick();
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
const undoEvent = keydown(root, { key: 'z', ctrlKey: true });
await tick();
expect(undoEvent.defaultPrevented).toBe(true);
expect(screen.component.getValue()).toBe(SOURCE);
const redoEvent = keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
await tick();
expect(redoEvent.defaultPrevented).toBe(true);
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
});
it('redoes with Ctrl+Y as well', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
await tick();
const root = editableIn(screen.container);
type(root, ' more');
await tick();
keydown(root, { key: 'z', metaKey: true });
await tick();
expect(screen.component.getValue()).toBe(SOURCE);
keydown(root, { key: 'y', ctrlKey: true });
await tick();
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
});
it('coalesces a typing burst into one undo step', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
await tick();
const root = editableIn(screen.container);
type(root, 'd');
type(root, 'e');
await tick();
expect(screen.component.getValue()).toBe('abcde');
keydown(root, { key: 'z', ctrlKey: true });
await tick();
expect(screen.component.getValue()).toBe('abc');
});
it('keeps a newline as its own undo step', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
await tick();
const root = editableIn(screen.container);
type(root, 'd');
type(root, '\n', 'insertLineBreak');
await tick();
expect(screen.component.getValue()).toBe('abcd\n');
keydown(root, { key: 'z', ctrlKey: true });
await tick();
expect(screen.component.getValue()).toBe('abcd');
keydown(root, { key: 'z', ctrlKey: true });
await tick();
expect(screen.component.getValue()).toBe('abc');
});
it('is a no-op when there is nothing to undo', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
await tick();
const root = editableIn(screen.container);
const event = keydown(root, { key: 'z', ctrlKey: true });
await tick();
expect(event.defaultPrevented).toBe(true);
expect(screen.component.getValue()).toBe('abc');
});
it('abandons the redo branch after a fresh edit', async () => {
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
await tick();
const root = editableIn(screen.container);
type(root, 'd');
await tick();
keydown(root, { key: 'z', ctrlKey: true });
await tick();
expect(screen.component.getValue()).toBe('abc');
type(root, 'e');
await tick();
keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
await tick();
expect(screen.component.getValue()).toBe('abce');
});
});
describe('ChatFormContenteditable Tab key', () => {
it('does not trap Tab (focus can leave the editable)', async () => {
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
await tick();
const root = editableIn(screen.container);
const event = keydown(root, { key: 'Tab' });
expect(event.defaultPrevented).toBe(false);
});
});
@@ -0,0 +1,701 @@
// Guards the clipboard contract of the chat-form contenteditable:
// copy/cut expose the markdown SOURCE of the selection (each badge
// contributes its full `[name](file://...)` link) and pasting such
// markdown re-renders the badges.
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { userEvent } from 'vitest/browser';
import { tick } from 'svelte';
import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte';
const SOURCE = 'hello [docs](file:///a/b) world';
const BADGE_SELECTOR = '[data-mention-badge="true"]';
function editableIn(container: HTMLElement): HTMLElement {
const el = container.querySelector('[role="textbox"]');
if (!(el instanceof HTMLElement)) throw new Error('contenteditable not rendered');
return el;
}
function setSelection(root: HTMLElement, place: (range: Range, root: HTMLElement) => void) {
const range = document.createRange();
place(range, root);
const selection = window.getSelection();
if (!selection) throw new Error('no selection');
selection.removeAllRanges();
selection.addRange(range);
}
function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') {
const data = new DataTransfer();
if (text) data.setData('text/plain', text);
const event = new ClipboardEvent(type, { clipboardData: data, bubbles: true, cancelable: true });
return { event, data };
}
describe('ChatFormContenteditable clipboard', () => {
it('copy exposes the markdown source of the selection', async () => {
const { container } = render(ChatFormContenteditable, { value: SOURCE });
await tick();
const root = editableIn(container);
setSelection(root, (range) => range.selectNodeContents(root));
const { event, data } = clipboardEvent('copy');
root.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(data.getData('text/plain')).toBe(SOURCE);
});
it('cut exposes the markdown source and removes the slice', async () => {
const { container } = render(ChatFormContenteditable, { value: SOURCE });
await tick();
const root = editableIn(container);
setSelection(root, (range) => {
const badge = root.querySelector(BADGE_SELECTOR);
if (!badge) throw new Error('badge not rendered');
range.setStartBefore(badge);
range.setEndAfter(badge);
});
const { event, data } = clipboardEvent('cut');
root.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(data.getData('text/plain')).toBe('[docs](file:///a/b)');
expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
expect(root.textContent).toBe('hello world');
});
it('paste of markdown mention links re-renders badges', async () => {
const { container } = render(ChatFormContenteditable, { value: 'hello ' });
await tick();
const root = editableIn(container);
root.focus();
setSelection(root, (range) => {
range.selectNodeContents(root);
range.collapse(false);
});
const { event } = clipboardEvent('paste', '[docs](file:///a/b) world');
root.dispatchEvent(event);
await tick();
expect(event.defaultPrevented).toBe(true);
const badge = root.querySelector(BADGE_SELECTOR);
expect(badge).not.toBeNull();
expect(badge!.getAttribute('data-mention-name')).toBe('docs');
expect(root.textContent).toContain('world');
});
it('paste without mention links keeps the DOM untouched', async () => {
const { container } = render(ChatFormContenteditable, { value: 'hello ' });
await tick();
const root = editableIn(container);
root.focus();
setSelection(root, (range) => {
range.selectNodeContents(root);
range.collapse(false);
});
const firstChild = root.firstChild;
const { event } = clipboardEvent('paste', 'plain text');
root.dispatchEvent(event);
await tick();
expect(event.defaultPrevented).toBe(true);
expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
// no rebuild: the live text node is the same instance
expect(root.firstChild).toBe(firstChild);
});
});
describe('ChatFormContenteditable code spans', () => {
it('renders inline code from the initial value', async () => {
const { container } = render(ChatFormContenteditable, { value: 'run `npm test` now' });
await tick();
const root = editableIn(container);
const code = root.querySelector('code[data-code-token="inline"]');
expect(code).not.toBeNull();
expect(code!.textContent).toBe('`npm test`');
});
it('renders a fenced code block with a language', async () => {
const source = 'before\n```js\nconst a = 1;\n```\nafter';
const { container } = render(ChatFormContenteditable, { value: source });
await tick();
const root = editableIn(container);
const code = root.querySelector('code[data-code-token="block"]');
expect(code).not.toBeNull();
expect(code!.textContent).toBe('```js\nconst a = 1;\n```');
});
it('copy exposes the markdown source of a selection spanning code', async () => {
const source = 'run `npm test` now';
const { container } = render(ChatFormContenteditable, { value: source });
await tick();
const root = editableIn(container);
setSelection(root, (range) => range.selectNodeContents(root));
const { event, data } = clipboardEvent('copy');
root.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(data.getData('text/plain')).toBe(source);
});
it('paste of a code span renders the styled element', async () => {
const { container } = render(ChatFormContenteditable, { value: 'run ' });
await tick();
const root = editableIn(container);
root.focus();
setSelection(root, (range) => {
range.selectNodeContents(root);
range.collapse(false);
});
const { event } = clipboardEvent('paste', '`npm test` now');
root.dispatchEvent(event);
await tick();
expect(event.defaultPrevented).toBe(true);
const code = root.querySelector('code[data-code-token="inline"]');
expect(code).not.toBeNull();
expect(code!.textContent).toBe('`npm test`');
expect(root.textContent).toContain('now');
});
it('highlights a fenced block content and stays byte-exact', async () => {
const source = '```js\nconst a = 1;\n```';
const { container } = render(ChatFormContenteditable, { value: source });
await tick();
const root = editableIn(container);
const code = root.querySelector('code[data-code-token="block"]');
expect(code).not.toBeNull();
expect(code!.querySelector('.hljs-keyword')).not.toBeNull();
expect(code!.textContent).toBe(source);
});
it('does not highlight inline code', async () => {
const { container } = render(ChatFormContenteditable, { value: 'run `const` now' });
await tick();
const root = editableIn(container);
expect(root.querySelector('[class*="hljs-"]')).toBeNull();
});
});
describe('ChatFormContenteditable code block escape hatches', () => {
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
const BLOCK_SELECTOR = 'code[data-code-token="block"]';
function blockIn(root: HTMLElement): HTMLElement {
const el = root.querySelector(BLOCK_SELECTOR);
if (!(el instanceof HTMLElement)) throw new Error('code block not rendered');
return el;
}
// Caret at the very start/end of the block's text (across highlight spans)
function placeCaretInBlock(root: HTMLElement, where: 'start' | 'end') {
const code = blockIn(root);
const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
let target: Node | null = null;
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
target = where === 'start' ? (target ?? n) : n;
}
if (!target) throw new Error('no text inside code block');
setSelection(root, (range) => {
range.setStart(target!, where === 'start' ? 0 : (target!.textContent ?? '').length);
range.collapse(true);
});
}
function caretContainer(): Node {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) throw new Error('no selection');
return selection.getRangeAt(0).startContainer;
}
it('pads a trailing code block with a br hatch that stays invisible to copy', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
// no permanent empty line above a leading block
expect(root.firstChild).toBe(blockIn(root));
expect(root.lastChild?.nodeName).toBe('BR');
setSelection(root, (range) => range.selectNodeContents(root));
const { event, data } = clipboardEvent('copy');
root.dispatchEvent(event);
expect(data.getData('text/plain')).toBe(BLOCK_SOURCE);
});
it('escapes a trailing code block with ArrowDown and types after it', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'end');
await userEvent.keyboard('{ArrowDown}');
expect(blockIn(root).contains(caretContainer())).toBe(false);
await userEvent.keyboard('x');
await tick();
expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
// the DOM holds no separator newline (it would render as a
// phantom empty line); serialization synthesizes it so the
// markdown source keeps the text below the block
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
// the stale trailing hatch is removed once real text follows the block
expect(root.lastChild?.nodeName).not.toBe('BR');
});
it('escapes a leading code block with ArrowUp and types before it', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'start');
await userEvent.keyboard('{ArrowUp}');
expect(blockIn(root).contains(caretContainer())).toBe(false);
// the transient hatch line exists while the caret sits on it
expect(root.firstChild?.nodeName).toBe('BR');
await userEvent.keyboard('y');
await tick();
expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
expect(root.textContent).toBe('y' + BLOCK_SOURCE);
expect(serializeContent(root)).toBe('y\n' + BLOCK_SOURCE);
// the typed text consumed the hatch
expect(root.firstChild?.nodeName).not.toBe('BR');
});
it('escapes a leading code block with ArrowLeft from its first character', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'start');
await userEvent.keyboard('{ArrowLeft}');
expect(blockIn(root).contains(caretContainer())).toBe(false);
expect(root.firstChild?.nodeName).toBe('BR');
});
it('removes the transient leading hatch when the caret moves back into the block', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'start');
await userEvent.keyboard('{ArrowUp}');
expect(root.firstChild?.nodeName).toBe('BR');
await userEvent.keyboard('{ArrowDown}');
await tick();
expect(blockIn(root).contains(caretContainer())).toBe(true);
expect(root.firstChild).toBe(blockIn(root));
});
it('extends the selection out of the block with Shift+ArrowDown', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'end');
await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}');
const selection = window.getSelection();
expect(selection).not.toBeNull();
expect(selection!.isCollapsed).toBe(false);
expect(blockIn(root).contains(selection!.getRangeAt(0).endContainer)).toBe(false);
});
it('line-separates text typed right after the closing fence', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'end');
// no arrow keys: the caret sits at the block's end edge, where the
// post-rebuild restore lands it, and the typed text renders on the
// line below the block
await userEvent.keyboard('x');
await tick();
// the text stays on the caret's line in the DOM (no phantom empty
// line); the source gets the separator newline
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
});
it('does not double the newline when Shift+Enter already added one', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'end');
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
await userEvent.keyboard('x');
await tick();
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
});
it('moves a caret stuck before the inserted newline onto the new line', async () => {
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\ntext after the code block'
});
await tick();
const root = editableIn(container);
root.focus();
// post-break DOM some browsers produce: the inserted newline plus
// the artificial trailing one, with the caret stuck BEFORE the
// inserted one (visually at the end of the old line)
root.appendChild(document.createTextNode('\n'));
root.appendChild(document.createTextNode('\n'));
setSelection(root, (range) => {
range.setStart(root.childNodes[2], 0);
range.collapse(true);
});
root.dispatchEvent(new InputEvent('input', { inputType: 'insertLineBreak', bubbles: true }));
await tick();
const selection = window.getSelection();
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
(BLOCK_SOURCE + '\ntext after the code block\n').length
);
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
});
it('appends the artificial trailing newline when the browser did not add one', async () => {
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\ntext after the code block'
});
await tick();
const root = editableIn(container);
root.focus();
// post-break DOM some browsers produce: a lone trailing \n (or a
// <br> the hatch sync strips). Collapsed by the renderer, so the
// caret looks stuck on the old line and the next typed character
// would consume the newline.
root.appendChild(document.createTextNode('\n'));
setSelection(root, (range) => {
range.setStart(root.childNodes[2], 1);
range.collapse(true);
});
root.dispatchEvent(new InputEvent('input', { inputType: 'insertLineBreak', bubbles: true }));
await tick();
const selection = window.getSelection();
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
(BLOCK_SOURCE + '\ntext after the code block\n').length
);
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
});
it('lands the caret on the new line with a single Shift+Enter after text below a block', async () => {
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\ntext after the code block'
});
await tick();
const root = editableIn(container);
root.focus();
setSelection(root, (range) => {
const text = root.childNodes[1];
range.setStart(text, (text.textContent ?? '').length);
range.collapse(true);
});
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
await tick();
const selection = window.getSelection();
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
(BLOCK_SOURCE + '\ntext after the code block\n').length
);
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
// the next typed character lands on the new line
await userEvent.keyboard('x');
await tick();
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\nx');
});
it('lets Backspace at the text start move into the block without a source fight', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'end');
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('create');
await tick();
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
// Backspace at the start of the text line: the separator newline
// is structural (synthesized while text follows the block), so
// the caret just moves to the block's edge - nothing is re-added
await userEvent.keyboard('{Home}');
await userEvent.keyboard('{Backspace}');
await tick();
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
expect(caretContainer()).toBe(root);
});
it('lets forward Delete eat the text after a block normally', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
placeCaretInBlock(root, 'end');
await userEvent.keyboard('{ArrowDown}');
await userEvent.keyboard('create');
await tick();
await userEvent.keyboard('{Home}');
await userEvent.keyboard('{Delete}');
await tick();
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nreate');
});
it('renders text after a block without a phantom empty line', async () => {
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\nhello'
});
await tick();
const root = editableIn(container);
expect(root.textContent).toBe(BLOCK_SOURCE + 'hello');
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nhello');
});
it('keeps an intentional blank line after a block out of the separator', async () => {
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\n\nhello'
});
await tick();
const root = editableIn(container);
expect(root.textContent).toBe(BLOCK_SOURCE + '\nhello');
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\n\nhello');
});
it('re-highlights while typing inside a block and keeps the caret', async () => {
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
await tick();
const root = editableIn(container);
root.focus();
// caret at the start of the block content (after the opening fence)
setSelection(root, (range) => {
const target = textOffsetToRange(root, 6);
range.setStart(target.startContainer, target.startOffset);
range.collapse(true);
});
await userEvent.keyboard('x');
await tick();
const code = blockIn(root);
expect(serializeContent(root)).toBe('```js\nxconst a = 1;\n```');
expect(code.textContent).toBe('```js\nxconst a = 1;\n```');
expect(code.querySelector('.hljs-number')).not.toBeNull();
const selection = window.getSelection();
expect(code.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
});
});
describe('ChatFormContenteditable Enter in code blocks', () => {
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
it('adds a line instead of submitting on plain Enter inside a block', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE,
onKeydown
});
await tick();
const root = editableIn(container);
root.focus();
// caret at the start of the block content (after the opening fence)
setSelection(root, (range) => {
const target = textOffsetToRange(root, 6);
range.setStart(target.startContainer, target.startOffset);
range.collapse(true);
});
await userEvent.keyboard('{Enter}');
await tick();
// consumed locally: the parent's submit handler never sees it
expect(onKeydown).not.toHaveBeenCalled();
expect(serializeContent(root)).toBe('```js\n\nconst a = 1;\n```');
const code = root.querySelector('code[data-code-token="block"]');
const selection = window.getSelection();
expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
});
it('adds a line after a still-open fence (no closing ``` yet)', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
value: '```js\nconst a = 1;',
onKeydown
});
await tick();
const root = editableIn(container);
root.focus();
// caret at the start of the block content (after the opening fence)
setSelection(root, (range) => {
const target = textOffsetToRange(root, 6);
range.setStart(target.startContainer, target.startOffset);
range.collapse(true);
});
await userEvent.keyboard('{Enter}');
await tick();
expect(onKeydown).not.toHaveBeenCalled();
expect(serializeContent(root)).toBe('```js\n\nconst a = 1;');
});
it('forwards plain Enter to the parent when the caret is outside a block', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE + '\nafter',
onKeydown
});
await tick();
const root = editableIn(container);
root.focus();
setSelection(root, (range) => {
range.selectNodeContents(root);
range.collapse(false);
});
await userEvent.keyboard('{Enter}');
expect(onKeydown).toHaveBeenCalledTimes(1);
expect(onKeydown.mock.calls[0][0].defaultPrevented).toBe(false);
});
it('forwards plain Enter on the trailing hatch line after a block', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE,
onKeydown
});
await tick();
const root = editableIn(container);
root.focus();
// root-level caret between the block and its trailing br hatch
setSelection(root, (range) => {
range.setStart(root, 1);
range.collapse(true);
});
await userEvent.keyboard('{Enter}');
expect(onKeydown).toHaveBeenCalledTimes(1);
});
it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
value: BLOCK_SOURCE,
onKeydown
});
await tick();
const root = editableIn(container);
root.focus();
setSelection(root, (range) => {
const target = textOffsetToRange(root, 6);
range.setStart(target.startContainer, target.startOffset);
range.collapse(true);
});
await userEvent.keyboard('{Control>}{Enter}{/Control}');
expect(onKeydown).toHaveBeenCalledWith(
expect.objectContaining({ key: 'Enter', ctrlKey: true })
);
expect(serializeContent(root)).toBe(BLOCK_SOURCE);
});
it('forwards Enter inside an inline code span', async () => {
const onKeydown = vi.fn();
const { container } = render(ChatFormContenteditable, {
value: 'run `npm test` now',
onKeydown
});
await tick();
const root = editableIn(container);
root.focus();
const code = root.querySelector('code[data-code-token="inline"]')!;
setSelection(root, (range) => {
range.setStart(code.firstChild!, 3);
range.collapse(true);
});
await userEvent.keyboard('{Enter}');
expect(onKeydown).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,112 @@
// Guards the Enter-key contract of the chat form against the
// fenced-code-block flow: while the caret sits inside a fenced
// block region - closed, or still OPEN while the user is typing
// one - plain Enter adds a line instead of submitting the message.
// The textarea path is covered here end-to-end (the contenteditable
// consumes the same case locally; see chat-form-contenteditable).
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { userEvent } from 'vitest/browser';
import { tick } from 'svelte';
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
import { settingsStore } from '$lib/stores/settings.svelte';
import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
function textareaIn(container: HTMLElement): HTMLTextAreaElement {
const el = container.querySelector('textarea');
if (!(el instanceof HTMLTextAreaElement)) throw new Error('textarea not rendered');
return el;
}
describe('ChatForm Enter in code blocks', () => {
beforeEach(() => {
settingsStore.updateConfig(SETTINGS_KEYS.SEND_ON_ENTER, true);
});
it('adds a line after a still-open fence instead of submitting', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
await tick();
const textarea = textareaIn(container);
await userEvent.click(textarea);
await userEvent.keyboard('```');
await tick();
await userEvent.keyboard('{Enter}');
await tick();
expect(onSubmit).not.toHaveBeenCalled();
expect(textarea.value).toBe('```\n');
});
it('keeps adding lines while the block stays open', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
await tick();
const textarea = textareaIn(container);
await userEvent.click(textarea);
await userEvent.keyboard('```js');
await tick();
await userEvent.keyboard('{Enter}');
await userEvent.keyboard('const a = 1;');
await userEvent.keyboard('{Enter}');
await tick();
expect(onSubmit).not.toHaveBeenCalled();
expect(textarea.value).toBe('```js\nconst a = 1;\n');
});
it('submits when the caret is before the opening fence', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
await tick();
const textarea = textareaIn(container);
await userEvent.click(textarea);
await userEvent.keyboard('```');
await tick();
textarea.setSelectionRange(0, 0);
await userEvent.keyboard('{Enter}');
await tick();
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it('submits on Enter outside a code block', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
await tick();
const textarea = textareaIn(container);
await userEvent.click(textarea);
await userEvent.keyboard('hello');
await tick();
await userEvent.keyboard('{Enter}');
await tick();
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it('submits on Ctrl+Enter even inside a code block', async () => {
const onSubmit = vi.fn();
const { container } = render(ChatFormTestWrapper, { onSubmit });
await tick();
const textarea = textareaIn(container);
await userEvent.click(textarea);
await userEvent.keyboard('```');
await tick();
await userEvent.keyboard('{Control>}{Enter}{/Control}');
await tick();
expect(onSubmit).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,64 @@
// Guards the @-mention picker's file_glob_search gate: when the server
// does not expose the tool (started without --tools) or the user disabled
// it, the picker still opens but explains why instead of firing searches
// that would only fail with "Search failed".
import { describe, it, expect, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { tick } from 'svelte';
import ChatFormMentionPicker from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
import { BuiltInTool } from '$lib/enums';
import { DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY } from '$lib/constants';
import type { OpenAIToolDefinition } from '$lib/types';
const FILE_SEARCH_DEF: OpenAIToolDefinition = {
type: 'function',
function: { name: BuiltInTool.FILE_GLOB_SEARCH, description: '', parameters: {} }
};
const FILE_SEARCH_KEY = `builtin:${BuiltInTool.FILE_GLOB_SEARCH}`;
// The store keeps its builtin tool list private; tests inject it through
// the reactive field so the derived gates recompute.
function setBuiltinTools(defs: OpenAIToolDefinition[]) {
(toolsStore as unknown as { _builtinTools: OpenAIToolDefinition[] })._builtinTools = defs;
}
function renderPicker() {
return render(ChatFormMentionPicker, {
isOpen: true,
query: 'main',
onClose: () => {},
onSelect: () => {}
});
}
afterEach(() => {
setBuiltinTools([]);
toolsStore.setToolEnabled(FILE_SEARCH_KEY, true);
localStorage.removeItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
});
describe('ChatFormMentionPicker file_glob_search gate', () => {
it('explains that file search is unavailable when the server has no tools', async () => {
setBuiltinTools([]);
renderPicker();
await tick();
expect(document.body.textContent).toContain(
'File search is unavailable on this server (started without --tools)'
);
});
it('explains that file search must be enabled when the user disabled it', async () => {
setBuiltinTools([FILE_SEARCH_DEF]);
toolsStore.setToolEnabled(FILE_SEARCH_KEY, false);
renderPicker();
await tick();
expect(document.body.textContent).toContain(
'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions'
);
});
});
@@ -0,0 +1,122 @@
// Guards the slash-command dispatch contract: commands dispatch only on
// explicit selection (Enter/click in the picker), never mid-typing.
// Typing `/model is broken` is prose until the command is picked - the
// buffer must survive; only an actual selection consumes the token.
import { describe, it, expect } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { tick } from 'svelte';
import ChatFormPickersHarness from './components/ChatFormPickersHarness.svelte';
describe('slash command dispatch', () => {
it('does not dispatch or clear the buffer when a space follows the name', async () => {
const screen = render(ChatFormPickersHarness);
await tick();
screen.component.type('/model is broken');
await tick();
const pickers = screen.component.getPickers();
expect(screen.component.getValue()).toBe('/model is broken');
expect(screen.component.getCalls()).not.toContain('openModelSelector');
expect(screen.component.getCalls().some((c) => c.startsWith('setValue:'))).toBe(false);
expect(pickers.isCommandPickerOpen).toBe(true);
expect(pickers.commandQuery).toBe('model');
});
it('dispatches /model on explicit selection and consumes the token', async () => {
const screen = render(ChatFormPickersHarness);
await tick();
screen.component.type('/model is broken');
await tick();
const pickers = screen.component.getPickers();
const model = pickers.availableCommands.find((c) => c.name === 'model');
if (!model) throw new Error('model command missing');
pickers.handleCommandSelect(model);
await tick();
expect(screen.component.getValue()).toBe('');
expect(screen.component.getCalls()).toContain('openModelSelector');
expect(pickers.isCommandPickerOpen).toBe(false);
});
it('seeds the prompt picker search from the token args on selection', async () => {
const screen = render(ChatFormPickersHarness);
await tick();
screen.component.type('/prompt weather');
await tick();
const pickers = screen.component.getPickers();
expect(pickers.isPromptPickerOpen).toBe(false);
const prompt = pickers.availableCommands.find((c) => c.name === 'prompt');
if (!prompt) throw new Error('prompt command missing');
pickers.handleCommandSelect(prompt);
await tick();
expect(screen.component.getValue()).toBe('');
expect(pickers.isPromptPickerOpen).toBe(true);
expect(pickers.promptSearchQuery).toBe('weather');
});
it('normalizes a partial /cwd token on selection and keeps it in the buffer', async () => {
const screen = render(ChatFormPickersHarness);
await tick();
screen.component.type('/cw docs');
await tick();
const pickers = screen.component.getPickers();
expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
if (!cwd) throw new Error('cwd command missing');
pickers.handleCommandSelect(cwd);
await tick();
expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
expect(pickers.workingDirectoryQuery).toBe('docs');
expect(screen.component.getValue()).toBe('/cwd docs');
});
it('syncs the /cwd token into the picker search while the picker is open', async () => {
const screen = render(ChatFormPickersHarness);
await tick();
const pickers = screen.component.getPickers();
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
if (!cwd) throw new Error('cwd command missing');
screen.component.type('/cwd docs');
pickers.handleCommandSelect(cwd);
await tick();
screen.component.type('/cwd docs/sub');
await tick();
expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
expect(pickers.workingDirectoryQuery).toBe('docs/sub');
expect(pickers.isCommandPickerOpen).toBe(false);
});
it('abandons the /cwd picker when the token is edited away from /cwd', async () => {
const screen = render(ChatFormPickersHarness);
await tick();
const pickers = screen.component.getPickers();
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
if (!cwd) throw new Error('cwd command missing');
screen.component.type('/cwd docs');
pickers.handleCommandSelect(cwd);
await tick();
screen.component.type('/cwdd docs');
await tick();
expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
});
});
@@ -0,0 +1,27 @@
<script lang="ts">
import { untrack } from 'svelte';
import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte';
interface Props {
value?: string;
}
let { value: initial = '' }: Props = $props();
let value = $state(untrack(() => initial));
let inputRef: ChatFormContenteditable | undefined = $state(undefined);
export function getValue() {
return value;
}
export function getCaretOffset() {
return inputRef?.getCaretOffset();
}
export function setCaretOffset(offset: number) {
inputRef?.setCaretOffset(offset);
}
</script>
<ChatFormContenteditable bind:this={inputRef} bind:value />
@@ -0,0 +1,51 @@
<script lang="ts">
import {
useChatFormPickers,
type UseChatFormPickersReturn
} from '$lib/hooks/use-chat-form-pickers.svelte';
let value = $state('');
let caretOffset = $state(0);
const calls: string[] = [];
const pickers = useChatFormPickers({
getValue: () => value,
setValue: (v) => {
value = v;
calls.push(`setValue:${v}`);
},
getCaretOffset: () => caretOffset,
setCaretOffset: (o) => {
caretOffset = o;
},
focusInput: () => {},
getShowModelSelector: () => true,
hasPrompts: () => true,
hasCwdTools: () => true,
getCwd: () => null,
getServerHome: () => null,
openModelSelector: () => {
calls.push('openModelSelector');
},
getPickersRef: () => undefined
});
// Simulate the user typing: update the buffer and run the input flow.
export function type(text: string) {
value = text;
caretOffset = text.length;
pickers.handleInput();
}
export function getValue() {
return value;
}
export function getCalls() {
return calls;
}
export function getPickers(): UseChatFormPickersReturn {
return pickers;
}
</script>
@@ -0,0 +1,12 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
let { onSubmit }: { onSubmit?: () => void } = $props();
let value = $state('');
</script>
<Tooltip.Provider>
<ChatForm bind:value {onSubmit} />
</Tooltip.Provider>
@@ -0,0 +1,65 @@
// Guards the legacy render-key migration: `renderUserContentAsMarkdown`
// and `renderThinkingAsMarkdown` (opt-INTO markdown) fold into the single
// `renderContentAsRawText` setting, with any explicit raw-text preference
// winning when the legacy keys disagree. Legacy keys are removed from the
// persisted config so they do not stay orphaned in localStorage.
import { beforeEach, describe, expect, it } from 'vitest';
import { settingsStore, config } from '$lib/stores/settings.svelte';
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
function seedConfig(stored: Record<string, unknown>) {
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
settingsStore.initialize();
}
function persisted(): Record<string, unknown> {
return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
}
describe('renderContentAsRawText migration', () => {
beforeEach(() => {
localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
settingsStore.initialize();
});
it('maps renderUserContentAsMarkdown=false to raw text', () => {
seedConfig({ renderUserContentAsMarkdown: false });
expect(config().renderContentAsRawText).toBe(true);
});
it('maps renderUserContentAsMarkdown=true to markdown', () => {
seedConfig({ renderUserContentAsMarkdown: true });
expect(config().renderContentAsRawText).toBe(false);
});
it('maps renderThinkingAsMarkdown=false to raw text', () => {
seedConfig({ renderThinkingAsMarkdown: false });
expect(config().renderContentAsRawText).toBe(true);
});
it('lets any explicit raw-text preference win when the legacy keys disagree', () => {
seedConfig({ renderUserContentAsMarkdown: true, renderThinkingAsMarkdown: false });
expect(config().renderContentAsRawText).toBe(true);
});
it('honors the intermediate renderUserContentAsRawText key from the PR branch', () => {
seedConfig({ renderUserContentAsRawText: true });
expect(config().renderContentAsRawText).toBe(true);
});
it('keeps an already-migrated value and cleans up the legacy keys', () => {
seedConfig({ renderContentAsRawText: false, renderUserContentAsMarkdown: false });
expect(config().renderContentAsRawText).toBe(false);
const stored = persisted();
expect(stored.renderUserContentAsMarkdown).toBeUndefined();
expect(stored.renderThinkingAsMarkdown).toBeUndefined();
expect(stored.renderUserContentAsRawText).toBeUndefined();
});
it('defaults to markdown when no legacy key exists', () => {
seedConfig({});
expect(config().renderContentAsRawText).toBe(false);
});
});
+36 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { highlightCode, trimCodePadding } from '$lib/utils/code';
import { highlightCode, splitGluedClosingCodeFences, trimCodePadding } from '$lib/utils/code';
describe('trimCodePadding', () => {
it('removes a single leading newline', () => {
@@ -101,3 +101,38 @@ describe('highlightCode', () => {
expect(html).toBe('&lt;script&gt;a &amp;&amp; b&lt;/script&gt;');
});
});
describe('splitGluedClosingCodeFences', () => {
it('splits text glued to a closing fence onto its own line', () => {
const input = "```ts\nlet foo = 'bar';\n```create this file on [Desktop](file:///a/b/)";
expect(splitGluedClosingCodeFences(input)).toBe(
"```ts\nlet foo = 'bar';\n```\ncreate this file on [Desktop](file:///a/b/)"
);
});
it('leaves a well-formed code block untouched', () => {
const input = "```ts\nlet foo = 'bar';\n```\ncreate this file on [Desktop](file:///a/b/)";
expect(splitGluedClosingCodeFences(input)).toBe(input);
});
it('leaves content without fences untouched', () => {
expect(splitGluedClosingCodeFences('hello world')).toBe('hello world');
});
it('keeps nested markdown fences inside a block intact', () => {
const input = '```md\n# Example\n```python\nprint(1)\n```\n```';
expect(splitGluedClosingCodeFences(input)).toBe(input);
});
it('splits every glued closing fence when several blocks are present', () => {
const input = '```ts\na\n```first words\n\n```js\nb\n```second words';
expect(splitGluedClosingCodeFences(input)).toBe(
'```ts\na\n```\nfirst words\n\n```js\nb\n```\nsecond words'
);
});
it('leaves a still-open fence untouched', () => {
const input = '```ts\nlet foo = 1;';
expect(splitGluedClosingCodeFences(input)).toBe(input);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { findCommandToken, takeCommandDismissSnapshot } from '$lib/utils';
describe('findCommandToken', () => {
it('returns null when the value does not start with a slash', () => {
expect(findCommandToken('hello /prompt')).toBeNull();
expect(findCommandToken('')).toBeNull();
expect(findCommandToken('prompt')).toBeNull();
});
it('parses a bare slash', () => {
expect(findCommandToken('/')).toEqual({ name: '', args: '', end: 1 });
});
it('parses a command name with no args', () => {
expect(findCommandToken('/prompt')).toEqual({ name: 'prompt', args: '', end: 7 });
});
it('parses a command name followed by a space', () => {
expect(findCommandToken('/prompt ')).toEqual({ name: 'prompt', args: '', end: 8 });
});
it('parses args after the command name', () => {
expect(findCommandToken('/prompt rev')).toEqual({ name: 'prompt', args: 'rev', end: 11 });
});
it('parses multi-word args', () => {
expect(findCommandToken('/prompt review code ')).toEqual({
name: 'prompt',
args: ' review code ',
end: 22
});
});
it('treats the whole run as the name when there is no space', () => {
expect(findCommandToken('/promptx')).toEqual({ name: 'promptx', args: '', end: 8 });
});
});
describe('takeCommandDismissSnapshot', () => {
it('returns null when there is no command token', () => {
expect(takeCommandDismissSnapshot('hello')).toBeNull();
});
it('captures the name and args', () => {
expect(takeCommandDismissSnapshot('/prompt rev')).toEqual({
name: 'prompt',
args: 'rev'
});
});
});
@@ -0,0 +1,205 @@
import { describe, expect, it } from 'vitest';
import { containsCodeSpan, isOffsetInCodeBlock, tokenizeContent } from '$lib/utils';
describe('tokenizeContent', () => {
it('tokenizes a plain text buffer with no badges', () => {
expect(tokenizeContent('hello world')).toEqual([{ kind: 'text', text: 'hello world' }]);
});
it('tokenizes a single badge', () => {
expect(tokenizeContent('[docs](file:///a/b)')).toEqual([
{ kind: 'badge', name: 'docs', path: '/a/b' }
]);
});
it('tokenizes text around a single badge', () => {
expect(tokenizeContent('hello [docs](file:///a/b) world')).toEqual([
{ kind: 'text', text: 'hello ' },
{ kind: 'badge', name: 'docs', path: '/a/b' },
{ kind: 'text', text: ' world' }
]);
});
it('tokenizes adjacent badges as separate tokens', () => {
expect(tokenizeContent('[a](file:///x)[b](file:///y)')).toEqual([
{ kind: 'badge', name: 'a', path: '/x' },
{ kind: 'badge', name: 'b', path: '/y' }
]);
});
it('leaves non-file links untouched in the stream', () => {
expect(tokenizeContent('see [foo](https://example.com) for details')).toEqual([
{ kind: 'text', text: 'see [foo](https://example.com) for details' }
]);
});
it('recognizes badges whose path contains spaces (macOS screenshots)', () => {
const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
const source = `[Screenshot 2026-07-28 at 17.21.50.png](file://${path}) `;
expect(tokenizeContent(source)).toEqual([
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
{ kind: 'text', text: ' ' }
]);
});
it('recognizes badges whose path lives in the macOS temp folder', () => {
const path =
'/var/folders/78/j28m7pn57wb34bfjwlskh62h0000gn/T/TemporaryItems/NSIRD_screencaptureui_GD0A2R/Screenshot 2026-07-28 at 17.23.28.png';
const source = `[Screenshot 2026-07-28 at 17.23.28.png](file://${path}) `;
expect(tokenizeContent(source)).toEqual([
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.23.28.png', path },
{ kind: 'text', text: ' ' }
]);
});
it('keeps text around a badge with spaces in the path', () => {
const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
const source = `see [Screenshot 2026-07-28 at 17.21.50.png](file://${path}) done`;
expect(tokenizeContent(source)).toEqual([
{ kind: 'text', text: 'see ' },
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
{ kind: 'text', text: ' done' }
]);
});
it('recognizes badges whose path contains a close parenthesis (macOS duplicate files)', () => {
const path = '/Users/foo/Screenshot (1).png';
const source = `[Screenshot (1).png](file://${path}) `;
expect(tokenizeContent(source)).toEqual([
{ kind: 'badge', name: 'Screenshot (1).png', path },
{ kind: 'text', text: ' ' }
]);
});
it('recognizes badges whose folder name is wrapped in parentheses', () => {
const path = '/Users/foo/Project (Stuff)/main.rs';
const source = `[main.rs](file://${path}) `;
expect(tokenizeContent(source)).toEqual([
{ kind: 'badge', name: 'main.rs', path },
{ kind: 'text', text: ' ' }
]);
});
it('recognizes adjacent badges back-to-back with no separator', () => {
const source = '[a](file:///p)[b](file:///q)';
expect(tokenizeContent(source)).toEqual([
{ kind: 'badge', name: 'a', path: '/p' },
{ kind: 'badge', name: 'b', path: '/q' }
]);
});
it('tokenizes inline code with the backticks included', () => {
expect(tokenizeContent('run `npm test` now')).toEqual([
{ kind: 'text', text: 'run ' },
{ kind: 'inlineCode', text: '`npm test`' },
{ kind: 'text', text: ' now' }
]);
});
it('tokenizes a fenced code block without a language', () => {
const source = 'before\n```\nconst a = 1;\n```\nafter';
expect(tokenizeContent(source)).toEqual([
{ kind: 'text', text: 'before\n' },
{ kind: 'codeBlock', text: '```\nconst a = 1;\n```' },
{ kind: 'text', text: '\nafter' }
]);
});
it('tokenizes a fenced code block with a language', () => {
const source = '```js\nconst a = 1;\n```';
expect(tokenizeContent(source)).toEqual([
{ kind: 'codeBlock', text: '```js\nconst a = 1;\n```' }
]);
});
it('prefers the fenced block over inline spans at triple backticks', () => {
expect(tokenizeContent('```a``` ```b```')).toEqual([
{ kind: 'codeBlock', text: '```a```' },
{ kind: 'text', text: ' ' },
{ kind: 'codeBlock', text: '```b```' }
]);
});
it('leaves an unclosed fence as plain text', () => {
expect(tokenizeContent('```js\nconst a = 1;')).toEqual([
{ kind: 'text', text: '```js\nconst a = 1;' }
]);
});
it('leaves an unclosed inline backtick as plain text', () => {
expect(tokenizeContent('run `npm test')).toEqual([{ kind: 'text', text: 'run `npm test' }]);
});
it('does not recognize badges inside code spans', () => {
expect(tokenizeContent('`[a](file:///p)`')).toEqual([
{ kind: 'inlineCode', text: '`[a](file:///p)`' }
]);
});
it('tokenizes badges and code spans side by side', () => {
expect(tokenizeContent('[a](file:///p) `x`')).toEqual([
{ kind: 'badge', name: 'a', path: '/p' },
{ kind: 'text', text: ' ' },
{ kind: 'inlineCode', text: '`x`' }
]);
});
});
describe('containsCodeSpan', () => {
it('detects inline code', () => {
expect(containsCodeSpan('run `npm test` now')).toBe(true);
});
it('detects a fenced block with a language', () => {
expect(containsCodeSpan('```js\nconst a = 1;\n```')).toBe(true);
});
it('detects a fenced block without a language', () => {
expect(containsCodeSpan('```\ncode\n```')).toBe(true);
});
it('ignores unclosed fences and lone backticks', () => {
expect(containsCodeSpan('```js\nconst a = 1;')).toBe(false);
expect(containsCodeSpan('run `npm test')).toBe(false);
expect(containsCodeSpan('``')).toBe(false);
});
it('ignores plain text and mention links', () => {
expect(containsCodeSpan('hello world')).toBe(false);
expect(containsCodeSpan('[a](file:///p)')).toBe(false);
});
});
describe('isOffsetInCodeBlock', () => {
const BLOCK = '```js\nconst a = 1;\n```';
it('is false with no fences in the buffer', () => {
expect(isOffsetInCodeBlock('hello world', 5)).toBe(false);
expect(isOffsetInCodeBlock('run `npm test` now', 10)).toBe(false);
});
it('is true right after the opening fence, before any content', () => {
expect(isOffsetInCodeBlock('```', 3)).toBe(true);
expect(isOffsetInCodeBlock('```js', 5)).toBe(true);
});
it('is true inside a still-open block while it is being typed', () => {
const open = '```js\nconst a = 1;';
expect(isOffsetInCodeBlock(open, open.length)).toBe(true);
});
it('is true inside a closed block and false outside it', () => {
expect(isOffsetInCodeBlock(BLOCK, 6)).toBe(true);
expect(isOffsetInCodeBlock(BLOCK, 0)).toBe(false);
expect(isOffsetInCodeBlock(BLOCK, BLOCK.length)).toBe(false);
expect(isOffsetInCodeBlock(BLOCK + '\nafter', BLOCK.length + 5)).toBe(false);
});
it('toggles per fence across multiple blocks', () => {
const two = BLOCK + '\ntext\n' + BLOCK;
const secondBlock = two.lastIndexOf(BLOCK);
expect(isOffsetInCodeBlock(two, secondBlock - 2)).toBe(false);
expect(isOffsetInCodeBlock(two, secondBlock + 6)).toBe(true);
expect(isOffsetInCodeBlock(two, two.length)).toBe(false);
});
});
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { badgeAwareWordJump, leadingBadgeEdgeOffset } from '$lib/utils';
// Layout of `hello [docs](file:///a/b) world foo`:
// "hello" 0-4, " " 5, badge 6-24 (length 19), " " 25, "world" 26-30, " " 31, "foo" 32-34
const BADGE = '[docs](file:///a/b)';
const SOURCE = `hello ${BADGE} world foo`;
const BADGE_START = 6;
const BADGE_END = 25;
describe('badgeAwareWordJump', () => {
it('returns null when the buffer has no badge', () => {
expect(badgeAwareWordJump('hello world', 0, 'forward')).toBeNull();
expect(badgeAwareWordJump('hello world', 11, 'backward')).toBeNull();
});
it('jumps forward onto a badge landing at its end, not the next word', () => {
expect(badgeAwareWordJump(SOURCE, BADGE_START, 'forward')).toBe(BADGE_END);
});
it('jumps forward from the space before a badge landing at its end', () => {
expect(badgeAwareWordJump(SOURCE, BADGE_START - 1, 'forward')).toBe(BADGE_END);
});
it('jumps backward over a badge landing at its start', () => {
expect(badgeAwareWordJump(SOURCE, BADGE_END, 'backward')).toBe(BADGE_START);
});
it('jumps backward from the next word onto the badge start', () => {
// caret at the start of "world"
expect(badgeAwareWordJump(SOURCE, BADGE_END + 1, 'backward')).toBe(BADGE_START);
});
it('returns null for jumps that cross no badge', () => {
// forward over "hello" only
expect(badgeAwareWordJump(SOURCE, 0, 'forward')).toBeNull();
// backward over "foo" only
expect(badgeAwareWordJump(SOURCE, SOURCE.length, 'backward')).toBeNull();
// backward away from the badge (over "hello")
expect(badgeAwareWordJump(SOURCE, BADGE_START, 'backward')).toBeNull();
});
it('treats a leading badge as one word in both directions', () => {
const source = `${BADGE} rest`;
expect(badgeAwareWordJump(source, 0, 'forward')).toBe(BADGE.length);
expect(badgeAwareWordJump(source, BADGE.length, 'backward')).toBe(0);
});
it('treats adjacent badges as separate words', () => {
// each badge is 14 chars: "[a](file:///x)" / "[b](file:///y)"
const source = '[a](file:///x)[b](file:///y)';
expect(badgeAwareWordJump(source, 0, 'forward')).toBe(14);
expect(badgeAwareWordJump(source, 14, 'forward')).toBe(28);
expect(badgeAwareWordJump(source, 28, 'backward')).toBe(14);
expect(badgeAwareWordJump(source, 14, 'backward')).toBe(0);
});
it('jumps over a badge following punctuation', () => {
// "foo," 0-3, " " 4, badge 5-23 (end 24), " bar" 24-27
const source = `foo, ${BADGE} bar`;
expect(badgeAwareWordJump(source, 0, 'forward')).toBeNull();
expect(badgeAwareWordJump(source, 3, 'forward')).toBe(24);
});
});
describe('leadingBadgeEdgeOffset', () => {
it('returns 0 when the caret sits exactly at a leading badge end', () => {
expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length)).toBe(0);
});
it('returns null when the caret is anywhere else', () => {
expect(leadingBadgeEdgeOffset(`${BADGE} rest`, 0)).toBeNull();
expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length + 2)).toBeNull();
});
it('returns null when the buffer does not start with a badge', () => {
expect(leadingBadgeEdgeOffset(SOURCE, BADGE_END)).toBeNull();
expect(leadingBadgeEdgeOffset('', 0)).toBeNull();
});
});
+3 -31
View File
@@ -3,12 +3,12 @@ import {
MENTION_BADGE_FILE_ICON_PATHS,
MENTION_BADGE_FOLDER_ICON_PATHS,
buildMentionInsertion,
containsFileMentionLink,
decodeFileLinkPath,
encodeFileLinkPath,
fileMentionLinkRe,
getMentionBadgeIconPaths,
getMentionBadgeLabel,
mentionLinkEndingAt
getMentionBadgeLabel
} from '$lib/utils';
import { FileMentionEntryType } from '$lib/enums';
@@ -35,6 +35,7 @@ describe('encodeFileLinkPath', () => {
describe('fileMentionLinkRe', () => {
it('matches a standard mention link', () => {
expect(fileMentionLinkRe().test('[docs](file:///a/b)')).toBe(true);
expect(containsFileMentionLink('[docs](file:///a/b)')).toBe(true);
});
it('does not match non-file links', () => {
@@ -171,32 +172,3 @@ describe('buildMentionInsertion', () => {
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 2, end: 1 })).toBeNull();
});
});
describe('mentionLinkEndingAt', () => {
const LINK = '[docs](file:///a/b)';
it('returns the extent when the caret is exactly at the link end', () => {
expect(mentionLinkEndingAt(`see ${LINK} here`, 4 + LINK.length)).toEqual({
start: 4,
end: 4 + LINK.length
});
});
it('returns null when the caret is inside or past the link', () => {
expect(mentionLinkEndingAt(LINK, LINK.length - 1)).toBeNull();
expect(mentionLinkEndingAt(`${LINK} `, LINK.length + 1)).toBeNull();
});
it('returns null for non-file links and plain text', () => {
expect(mentionLinkEndingAt('[foo](https://example.com)', 26)).toBeNull();
expect(mentionLinkEndingAt('plain', 5)).toBeNull();
});
it('picks the link that ends at the caret when several exist', () => {
const value = `${LINK} and ${LINK}`;
expect(mentionLinkEndingAt(value, value.length)).toEqual({
start: value.length - LINK.length,
end: value.length
});
});
});
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { SourceHistory } from '$lib/utils';
describe('SourceHistory', () => {
it('coalesces pushes inside the group window into one undo step', () => {
const h = new SourceHistory(100, 800);
h.push({ value: '', caret: 0 }, 1000);
h.push({ value: 'a', caret: 1 }, 1200);
h.push({ value: 'ab', caret: 2 }, 1500);
expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
expect(h.undo({ value: '', caret: 0 })).toBeNull();
});
it('starts a new group once the window has passed', () => {
const h = new SourceHistory(100, 800);
h.push({ value: '', caret: 0 }, 1000);
h.push({ value: 'abc', caret: 3 }, 2000);
expect(h.undo({ value: 'abcdef', caret: 6 })).toEqual({ value: 'abc', caret: 3 });
expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
});
it('newGroup forces a separate entry even inside the window', () => {
const h = new SourceHistory(100, 800);
h.push({ value: '', caret: 0 }, 1000);
h.push({ value: 'abc', caret: 3 }, 1100, true);
expect(h.undo({ value: 'abc\n', caret: 4 })).toEqual({ value: 'abc', caret: 3 });
expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
});
it('redo round-trips and a fresh push clears the redo stack', () => {
const h = new SourceHistory(100, 800);
h.push({ value: '', caret: 0 }, 1000);
const undone = h.undo({ value: 'abc', caret: 3 });
expect(undone).toEqual({ value: '', caret: 0 });
expect(h.redo({ value: '', caret: 0 })).toEqual({ value: 'abc', caret: 3 });
h.undo({ value: 'abc', caret: 3 });
h.push({ value: '', caret: 0 }, 5000);
expect(h.redo({ value: 'x', caret: 1 })).toBeNull();
});
it('starts a new group on the first edit after an undo', () => {
const h = new SourceHistory(100, 800);
h.push({ value: '', caret: 0 }, 1000);
h.undo({ value: 'abc', caret: 3 });
h.push({ value: '', caret: 0 }, 1200);
expect(h.undo({ value: 'x', caret: 1 })).toEqual({ value: '', caret: 0 });
});
it('evicts the oldest entry past the limit', () => {
const h = new SourceHistory(2, 800);
h.push({ value: 'one', caret: 0 }, 1000);
h.push({ value: 'two', caret: 0 }, 2000);
h.push({ value: 'three', caret: 0 }, 3000);
expect(h.undo({ value: 'cur', caret: 0 })).toEqual({ value: 'three', caret: 0 });
expect(h.undo({ value: 'three', caret: 0 })).toEqual({ value: 'two', caret: 0 });
expect(h.undo({ value: 'two', caret: 0 })).toBeNull();
});
});