Merge commit 'deae5ee133a3c4c56fbd46c17c8c2103af3bd643' into concedo_experimental

# Conflicts:
#	.devops/openvino.Dockerfile
#	.github/workflows/build-apple.yml
#	.github/workflows/build-cuda-ubuntu.yml
#	.github/workflows/docker.yml
#	.github/workflows/release.yml
#	.github/workflows/server-sanitize.yml
#	.github/workflows/ui-build-self-hosted.yml
#	.github/workflows/ui-build.yml
#	.github/workflows/ui-publish.yml
#	.github/workflows/ui-self-hosted.yml
#	.github/workflows/ui.yml
#	CMakeLists.txt
#	docs/backend/snapdragon/CMakeUserPresets.json
#	docs/backend/snapdragon/README.md
#	docs/backend/snapdragon/developer.md
#	docs/backend/snapdragon/linux.md
#	docs/backend/snapdragon/windows.md
#	docs/ops.md
#	docs/ops/Vulkan.csv
#	ggml/cmake/ggml-config.cmake.in
#	ggml/src/ggml-cpu/CMakeLists.txt
#	ggml/src/ggml-cpu/kleidiai/kernels.cpp
#	ggml/src/ggml-cpu/kleidiai/kernels.h
#	ggml/src/ggml-cpu/kleidiai/kleidiai.cpp
#	ggml/src/ggml-hexagon/ggml-hexagon.cpp
#	ggml/src/ggml-hexagon/htp-opnode.h
#	ggml/src/ggml-hexagon/htp/CMakeLists.txt
#	ggml/src/ggml-hexagon/htp/act-ops.c
#	ggml/src/ggml-hexagon/htp/cpy-ops.c
#	ggml/src/ggml-hexagon/htp/dma-queue.h
#	ggml/src/ggml-hexagon/htp/flash-attn-ops.c
#	ggml/src/ggml-hexagon/htp/get-rows-ops.c
#	ggml/src/ggml-hexagon/htp/hex-utils.h
#	ggml/src/ggml-hexagon/htp/htp-ctx.h
#	ggml/src/ggml-hexagon/htp/htp-ops.h
#	ggml/src/ggml-hexagon/htp/htp-tensor.c
#	ggml/src/ggml-hexagon/htp/htp-tensor.h
#	ggml/src/ggml-hexagon/htp/hvx-arith.h
#	ggml/src/ggml-hexagon/htp/main.c
#	ggml/src/ggml-hexagon/htp/matmul-ops.c
#	ggml/src/ggml-hexagon/htp/matmul-ops.h
#	ggml/src/ggml-hexagon/htp/set-rows-ops.c
#	ggml/src/ggml-rpc/CMakeLists.txt
#	scripts/snapdragon/ggml-hexagon-profile.py
#	scripts/snapdragon/ggml-hexagon-trace.py
#	tests/test-backend-ops.cpp
#	tools/cli/README.md
#	tools/llama-bench/llama-bench.cpp
#	tools/rpc/README.md
#	tools/server/README.md
#	tools/ui/tests/stories/a11y/ChatScreenForm.a11y.stories.svelte
This commit is contained in:
Concedo
2026-08-28 19:31:29 +08:00
328 changed files with 4599 additions and 2556 deletions
+10 -1
View File
@@ -87,6 +87,9 @@ struct mtmd_cli_context {
mtmd::bitmaps bitmaps;
std::vector<mtmd_helper::video_ptr> videos;
mtmd_helper_init_opt init_opt = mtmd_helper_init_opt_default();
std::string video_ffmpeg_bin_dir;
mtmd::batch_ptr mbatch;
// chat template
@@ -170,6 +173,12 @@ struct mtmd_cli_context {
LOG_ERR("Failed to load vision model from %s\n", clip_path);
exit(1);
}
video_ffmpeg_bin_dir = params.video_ffmpeg_bin_dir;
init_opt.video_params.fps_target = params.video_fps;
init_opt.video_params.timestamp_interval_ms = params.video_timestamp_interval_ms;
init_opt.video_params.ffmpeg_bin_dir = video_ffmpeg_bin_dir.empty()
? nullptr : video_ffmpeg_bin_dir.c_str();
}
bool check_antiprompt(const llama_tokens & generated_tokens) {
@@ -184,7 +193,7 @@ struct mtmd_cli_context {
}
bool load_media(const std::string & fname) {
auto res = mtmd_helper_bitmap_init_from_file(ctx_vision.get(), fname.c_str(), false);
auto res = mtmd_helper_bitmap_init_from_file(ctx_vision.get(), fname.c_str(), false, init_opt);
if (!res.bitmap) {
return false;
}
+19 -9
View File
@@ -370,14 +370,18 @@ static bool is_webp_file(const unsigned char * buf, size_t len) {
}
#ifdef MTMD_VIDEO
static mtmd_bitmap * decode_webp_with_ffmpeg(mtmd_context * mctx, const unsigned char * buf, size_t len, bool placeholder);
static mtmd_bitmap * decode_webp_with_ffmpeg(mtmd_context * mctx, const unsigned char * buf, size_t len, bool placeholder,
const mtmd_helper_video_init_params & params);
#endif
mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_buf(mtmd_context * ctx, const unsigned char * buf, size_t len, bool placeholder) {
mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_buf(mtmd_context * ctx, const unsigned char * buf, size_t len, bool placeholder,
mtmd_helper_init_opt opt) {
// calculate the hash if needed
std::string id;
mtmd_bitmap * result = nullptr;
GGML_UNUSED(opt); // only used by video code paths
if (!placeholder) {
// use sha256 to prevent cache poisoning
id = hash_sha256_hex(buf, len);
@@ -415,7 +419,7 @@ mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_buf(mtmd_context * ctx,
#ifdef MTMD_VIDEO
// stb_image does not support webp; decode it with ffmpeg as a single frame
if (!result && is_webp_file(buf, len)) {
result = decode_webp_with_ffmpeg(ctx, buf, len, placeholder);
result = decode_webp_with_ffmpeg(ctx, buf, len, placeholder, opt.video_params);
if (!result) {
LOG_ERR("%s: failed to decode webp buffer\n", __func__);
return {nullptr, nullptr};
@@ -428,8 +432,7 @@ mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_buf(mtmd_context * ctx,
// last try: load as video
#ifdef MTMD_VIDEO
if (!result) {
auto params = mtmd_helper_video_init_params_default();
auto video_ctx = mtmd_helper_video_init_from_buf(ctx, buf, len, params);
auto video_ctx = mtmd_helper_video_init_from_buf(ctx, buf, len, opt.video_params);
if (!video_ctx) {
LOG_ERR("%s: failed to decode buffer as either image/audio/video\n", __func__);
return {nullptr, nullptr};
@@ -457,7 +460,8 @@ mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_buf(mtmd_context * ctx,
return {nullptr, nullptr};
}
mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_file(mtmd_context * ctx, const char * fname, bool placeholder) {
mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_file(mtmd_context * ctx, const char * fname, bool placeholder,
mtmd_helper_init_opt opt) {
#ifdef _WIN32
int wlen = MultiByteToWideChar(CP_UTF8, 0, fname, -1, NULL, 0);
if (!wlen) {
@@ -498,7 +502,7 @@ mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_file(mtmd_context * ctx,
return {nullptr, nullptr};
}
return mtmd_helper_bitmap_init_from_buf(ctx, buf.data(), buf.size(), placeholder);
return mtmd_helper_bitmap_init_from_buf(ctx, buf.data(), buf.size(), placeholder, opt);
}
bool mtmd_helper_support_video(mtmd_context * ctx) {
@@ -856,6 +860,12 @@ mtmd_helper_video_init_params mtmd_helper_video_init_params_default() {
};
}
mtmd_helper_init_opt mtmd_helper_init_opt_default() {
return {
/* video_params */ mtmd_helper_video_init_params_default(),
};
}
static std::string video_resolve_bin(const char * bin_dir, const char * name) {
if (!bin_dir || bin_dir[0] == '\0') {
return name; // rely on PATH
@@ -877,8 +887,8 @@ static std::string video_resolve_bin(const char * bin_dir, const char * name) {
}
#ifdef MTMD_VIDEO
static mtmd_bitmap * decode_webp_with_ffmpeg(mtmd_context * mctx, const unsigned char * buf, size_t len, bool placeholder) {
auto params = mtmd_helper_video_init_params_default();
static mtmd_bitmap * decode_webp_with_ffmpeg(mtmd_context * mctx, const unsigned char * buf, size_t len, bool placeholder,
const mtmd_helper_video_init_params & params) {
mtmd_helper_video vctx;
vctx.mctx = mctx;
vctx.input_buf.assign(buf, buf + len);
+28 -10
View File
@@ -23,6 +23,23 @@ extern "C" {
struct mtmd_helper_video;
typedef struct mtmd_helper_video mtmd_helper_video;
struct mtmd_helper_video_init_params {
float fps_target; // desired output fps; <= 0 means use the video's native fps, defaulted to 4.0f
const char * ffmpeg_bin_dir; // directory containing ffmpeg/ffprobe binaries; NULL means search PATH
int64_t timestamp_interval_ms; // interval for adding timestamp as text chunk (example: "[10m50.5s]"); <= 0 means no timestamp, defaulted to 5000ms
// TODO @ngxson : allow "placeholder" bitmap output for counting tokens
};
MTMD_API struct mtmd_helper_video_init_params mtmd_helper_video_init_params_default(void);
// opt for mtmd_helper_bitmap_init_from_*()
struct mtmd_helper_init_opt {
struct mtmd_helper_video_init_params video_params;
};
typedef struct mtmd_helper_init_opt mtmd_helper_init_opt;
MTMD_API struct mtmd_helper_init_opt mtmd_helper_init_opt_default(void);
// Set callback for all future logging events.
// If this is not called, or NULL is supplied, everything is output on stderr.
// Note: this also call mtmd_log_set() internally
@@ -40,7 +57,11 @@ struct mtmd_helper_bitmap_wrapper {
// it calls mtmd_helper_bitmap_init_from_buf() internally
// returns nullptr on failure
// this function is thread-safe
MTMD_API struct mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_file(mtmd_context * ctx, const char * fname, bool placeholder);
MTMD_API struct mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_file(
mtmd_context * ctx,
const char * fname,
bool placeholder,
struct mtmd_helper_init_opt opt);
// helper function to construct a mtmd_bitmap from a buffer containing a file
// supported formats:
@@ -53,7 +74,11 @@ MTMD_API struct mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_file(mtm
// - output bitmap will have SHA-256 hash (hex string) as the ID
// returns nullptr on failure
// this function is thread-safe
MTMD_API struct mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_buf(mtmd_context * ctx, const unsigned char * buf, size_t len, bool placeholder);
MTMD_API struct mtmd_helper_bitmap_wrapper mtmd_helper_bitmap_init_from_buf(
mtmd_context * ctx,
const unsigned char * buf, size_t len,
bool placeholder,
struct mtmd_helper_init_opt opt);
// helper to count the total number of tokens from a list of chunks, useful to keep track of KV cache
MTMD_API size_t mtmd_helper_get_n_tokens(const mtmd_input_chunks * chunks);
@@ -124,14 +149,7 @@ struct mtmd_helper_video_info {
int32_t n_frames; // estimated total frames at effective fps (-1 if unknown)
};
struct mtmd_helper_video_init_params {
float fps_target; // desired output fps; <= 0 means use the video's native fps, defaulted to 4.0f
const char * ffmpeg_bin_dir; // directory containing ffmpeg/ffprobe binaries; NULL means search PATH
int64_t timestamp_interval_ms; // interval for adding timestamp as text chunk (example: "[10m50.5s]"); <= 0 means no timestamp, defaulted to 5000ms
// TODO @ngxson : allow "placeholder" bitmap output for counting tokens
};
MTMD_API struct mtmd_helper_video_init_params mtmd_helper_video_init_params_default(void);
// note: mtmd_helper_video_init_params is defined at the top, as it is part of mtmd_helper_init_opt
// returns NULL on failure (ffprobe not found, file unreadable, etc.)
MTMD_API mtmd_helper_video * mtmd_helper_video_init(
+23 -11
View File
@@ -910,12 +910,17 @@ size_t validate_utf8(const std::string& text) {
return len;
}
server_tokens process_mtmd_prompt(mtmd_context * mctx, const std::string & prompt, const std::vector<raw_buffer> & files, bool is_placeholder) {
server_tokens process_mtmd_prompt(
mtmd_context * mctx,
const std::string & prompt,
const std::vector<raw_buffer> & files,
const mtmd_helper_init_opt & init_opt,
bool is_placeholder) {
// these will be freed upon going out of scope
mtmd::bitmaps bitmaps;
std::vector<mtmd_helper::video_ptr> videos;
for (auto & file : files) {
auto out = mtmd_helper_bitmap_init_from_buf(mctx, file.data(), file.size(), is_placeholder);
auto out = mtmd_helper_bitmap_init_from_buf(mctx, file.data(), file.size(), is_placeholder, init_opt);
if (!out.bitmap) {
throw std::runtime_error("Failed to load image or audio file");
}
@@ -956,7 +961,7 @@ server_tokens process_mtmd_prompt(mtmd_context * mctx, const std::string & promp
* - "prompt": [12, 34, "string", 56, 78]
* - "prompt": { "prompt_string": "string", "multimodal_data": [ "base64" ] }
*/
static server_tokens tokenize_input_subprompt(const llama_vocab * vocab, mtmd_context * mctx, const json & json_prompt, bool add_special, bool parse_special) {
static server_tokens tokenize_input_subprompt(const llama_vocab * vocab, mtmd_context * mctx, const json & json_prompt, bool add_special, bool parse_special, const mtmd_helper_init_opt & init_opt) {
constexpr char JSON_STRING_PROMPT_KEY[] = "prompt_string";
constexpr char JSON_MTMD_DATA_KEY[] = "multimodal_data";
const bool has_mtmd = mctx != nullptr;
@@ -979,7 +984,7 @@ static server_tokens tokenize_input_subprompt(const llama_vocab * vocab, mtmd_co
for (const auto & entry : json_prompt.at(JSON_MTMD_DATA_KEY)) {
files.push_back(base64_decode(entry));
}
return process_mtmd_prompt(mctx, json_prompt.at(JSON_STRING_PROMPT_KEY), files);
return process_mtmd_prompt(mctx, json_prompt.at(JSON_STRING_PROMPT_KEY), files, init_opt);
} else {
// Not multimodal, but contains a subobject.
llama_tokens tmp = tokenize_mixed(vocab, json_prompt.at(JSON_STRING_PROMPT_KEY), add_special, parse_special);
@@ -990,15 +995,15 @@ static server_tokens tokenize_input_subprompt(const llama_vocab * vocab, mtmd_co
}
}
std::vector<server_tokens> tokenize_input_prompts(const llama_vocab * vocab, mtmd_context * mctx, const json & json_prompt, bool add_special, bool parse_special) {
std::vector<server_tokens> tokenize_input_prompts(const llama_vocab * vocab, mtmd_context * mctx, const json & json_prompt, bool add_special, bool parse_special, const mtmd_helper_init_opt & init_opt) {
std::vector<server_tokens> result;
if (json_prompt.is_array() && !json_is_array_and_contains_numbers(json_prompt)) {
result.reserve(json_prompt.size());
for (const auto & p : json_prompt) {
result.push_back(tokenize_input_subprompt(vocab, mctx, p,add_special, parse_special));
result.push_back(tokenize_input_subprompt(vocab, mctx, p, add_special, parse_special, init_opt));
}
} else {
result.push_back(tokenize_input_subprompt(vocab, mctx, json_prompt, add_special, parse_special));
result.push_back(tokenize_input_subprompt(vocab, mctx, json_prompt, add_special, parse_special, init_opt));
}
if (result.empty()) {
throw std::runtime_error("\"prompt\" must not be empty");
@@ -1280,6 +1285,12 @@ json oaicompat_chat_params_parse(
if (inputs.continue_final_message != COMMON_CHAT_CONTINUATION_NONE && inputs.add_generation_prompt) {
throw std::invalid_argument("Cannot set both add_generation_prompt and continue_final_message to true.");
}
if (inputs.continue_final_message != COMMON_CHAT_CONTINUATION_NONE
&& !inputs.messages.empty()
&& inputs.messages.back().role == "assistant"
&& !inputs.messages.back().tool_calls.empty()) {
throw std::invalid_argument("Cannot continue an assistant message that contains tool calls.");
}
inputs.reasoning_format = opt.reasoning_format;
if (body.contains("reasoning_format")) {
inputs.reasoning_format = common_reasoning_format_from_name(body.at("reasoning_format").get<std::string>());
@@ -1781,7 +1792,8 @@ server_tokens format_prompt_rerank(
const struct llama_vocab * vocab,
mtmd_context * mctx,
const std::string & query,
const std::string & doc) {
const std::string & doc,
const mtmd_helper_init_opt & init_opt) {
server_tokens result = {};
const char * rerank_prompt = llama_model_chat_template(model, "rerank");
@@ -1790,12 +1802,12 @@ server_tokens format_prompt_rerank(
std::string prompt = rerank_prompt;
string_replace_all(prompt, "{query}" , query);
string_replace_all(prompt, "{document}", doc );
server_tokens tokens = tokenize_input_subprompt(vocab, mctx, prompt, false, true);
server_tokens tokens = tokenize_input_subprompt(vocab, mctx, prompt, false, true, init_opt);
result.push_back(tokens);
} else {
// Get EOS token - use SEP token as fallback if EOS is not available
server_tokens query_tokens = tokenize_input_subprompt(vocab, mctx, query, false, false);
server_tokens doc_tokens = tokenize_input_subprompt(vocab, mctx, doc, false, false);
server_tokens query_tokens = tokenize_input_subprompt(vocab, mctx, query, false, false, init_opt);
server_tokens doc_tokens = tokenize_input_subprompt(vocab, mctx, doc, false, false, init_opt);
llama_token eos_token = llama_vocab_eos(vocab);
if (eos_token == LLAMA_TOKEN_NULL) {
eos_token = llama_vocab_sep(vocab);
+11 -3
View File
@@ -5,6 +5,7 @@
#include "llama.h"
#include "chat.h"
#include "mtmd.h"
#include "mtmd-helper.h"
#include "json.h"
@@ -269,7 +270,12 @@ size_t validate_utf8(const std::string& text);
// process mtmd prompt, return the server_tokens containing both text tokens and media chunks
// if is_placeholder is true, the media chunk will be treated as placeholder for counting tokens; the output tokens are not usable for actual inference (e.g. for submitting a task to server_queue)
server_tokens process_mtmd_prompt(mtmd_context * mctx, const std::string & prompt, const std::vector<raw_buffer> & files, bool is_placeholder = false);
server_tokens process_mtmd_prompt(
mtmd_context * mctx,
const std::string & prompt,
const std::vector<raw_buffer> & files,
const mtmd_helper_init_opt & init_opt,
bool is_placeholder = false);
/**
* break the input "prompt" object into multiple prompt if needed, then tokenize them
@@ -289,7 +295,8 @@ std::vector<server_tokens> tokenize_input_prompts(
mtmd_context * mctx,
const json & json_prompt,
bool add_special,
bool parse_special);
bool parse_special,
const mtmd_helper_init_opt & init_opt);
//
// OAI utils
@@ -538,7 +545,8 @@ server_tokens format_prompt_rerank(
const struct llama_vocab * vocab,
mtmd_context * mctx,
const std::string & query,
const std::string & doc);
const std::string & doc,
const mtmd_helper_init_opt & init_opt);
// simple implementation of a pipe
// used for streaming data between threads
+19 -12
View File
@@ -794,6 +794,8 @@ public:
llama_model * model_tgt = nullptr;
mtmd_context * mctx = nullptr;
// note: video_params.ffmpeg_bin_dir points into params_base, which outlives this struct
mtmd_helper_init_opt init_opt = mtmd_helper_init_opt_default();
const llama_vocab * vocab = nullptr;
server_queue queue_tasks;
@@ -1118,6 +1120,11 @@ private:
}
SRV_INF("loaded multimodal model, '%s'\n", mmproj_path.c_str());
init_opt.video_params.fps_target = params_base.video_fps;
init_opt.video_params.timestamp_interval_ms = params_base.video_timestamp_interval_ms;
init_opt.video_params.ffmpeg_bin_dir = params_base.video_ffmpeg_bin_dir.empty()
? nullptr : params_base.video_ffmpeg_bin_dir.c_str();
if (params_base.ctx_shift) {
params_base.ctx_shift = false;
SRV_WRN("%s\n", "ctx_shift is not supported by multimodal, it will be disabled");
@@ -2134,9 +2141,9 @@ private:
try {
auto & prompt = task.cli_prompt;
if (mctx != nullptr) {
task.tokens = process_mtmd_prompt(mctx, prompt, task.cli_files);
task.tokens = process_mtmd_prompt(mctx, prompt, task.cli_files, init_opt);
} else {
task.tokens = std::move(tokenize_input_prompts(vocab, mctx, prompt, true, true)[0]);
task.tokens = std::move(tokenize_input_prompts(vocab, mctx, prompt, true, true, init_opt)[0]);
}
task.cli_prompt.clear();
task.cli_files.clear();
@@ -4165,10 +4172,10 @@ std::unique_ptr<server_res_generator> server_routes::handle_completions_impl(
if (res_type != TASK_RESPONSE_TYPE_NONE && ctx_server.mctx != nullptr) {
// This is the case used by OAI compatible chat path with MTMD. TODO It can be moved to the path below.
inputs.push_back(process_mtmd_prompt(ctx_server.mctx, prompt.get<std::string>(), files));
inputs.push_back(process_mtmd_prompt(ctx_server.mctx, prompt.get<std::string>(), files, ctx_server.init_opt));
} else {
// Everything else, including multimodal completions.
inputs = tokenize_input_prompts(ctx_server.vocab, ctx_server.mctx, prompt, true, true);
inputs = tokenize_input_prompts(ctx_server.vocab, ctx_server.mctx, prompt, true, true, ctx_server.init_opt);
}
// tasks.reserve(inputs.size()); // TODO: this is inaccurate due to child tasks
@@ -4752,7 +4759,7 @@ void server_routes::init_routes() {
data["input_extra"] = input_extra; // default to empty array if it's not exist
std::string prompt = json_value(data, "prompt", std::string());
std::vector<server_tokens> tokenized_prompts = tokenize_input_prompts(ctx_server.vocab, ctx_server.mctx, prompt, false, true);
std::vector<server_tokens> tokenized_prompts = tokenize_input_prompts(ctx_server.vocab, ctx_server.mctx, prompt, false, true, ctx_server.init_opt);
SRV_DBG("creating infill tasks, n_prompts = %d\n", (int) tokenized_prompts.size());
data["prompt"] = format_prompt_infill(
ctx_server.vocab,
@@ -4816,7 +4823,7 @@ void server_routes::init_routes() {
};
this->post_chat_completions_tok = [this](const server_http_req & req) {
return handle_count_tokens(ctx_server.vocab, ctx_server.mctx, req, TASK_RESPONSE_TYPE_OAI_CHAT);
return handle_count_tokens(ctx_server.vocab, ctx_server.mctx, ctx_server.init_opt, req, TASK_RESPONSE_TYPE_OAI_CHAT);
};
this->post_control = [this](const server_http_req & req) {
@@ -4875,7 +4882,7 @@ void server_routes::init_routes() {
};
this->post_responses_tok_oai = [this](const server_http_req & req) {
return handle_count_tokens(ctx_server.vocab, ctx_server.mctx, req, TASK_RESPONSE_TYPE_OAI_RESP);
return handle_count_tokens(ctx_server.vocab, ctx_server.mctx, ctx_server.init_opt, req, TASK_RESPONSE_TYPE_OAI_RESP);
};
this->post_transcriptions_oai = [this](const server_http_req & req) {
@@ -4925,7 +4932,7 @@ void server_routes::init_routes() {
};
this->post_anthropic_count_tokens = [this](const server_http_req & req) {
return handle_count_tokens(ctx_server.vocab, ctx_server.mctx, req, TASK_RESPONSE_TYPE_ANTHROPIC);
return handle_count_tokens(ctx_server.vocab, ctx_server.mctx, ctx_server.init_opt, req, TASK_RESPONSE_TYPE_ANTHROPIC);
};
// same with handle_chat_completions, but without inference part
@@ -5058,7 +5065,7 @@ void server_routes::init_routes() {
std::vector<server_task> tasks;
tasks.reserve(documents.size());
for (size_t i = 0; i < documents.size(); i++) {
auto tmp = format_prompt_rerank(ctx_server.model_tgt, ctx_server.vocab, ctx_server.mctx, query, documents[i]);
auto tmp = format_prompt_rerank(ctx_server.model_tgt, ctx_server.vocab, ctx_server.mctx, query, documents[i], ctx_server.init_opt);
server_task task = server_task(SERVER_TASK_TYPE_RERANK);
task.id = rd.get_new_id();
task.tokens = std::move(tmp);
@@ -5296,7 +5303,7 @@ std::unique_ptr<server_res_generator> server_routes::handle_embeddings_impl(cons
}
}
auto tokenized_prompts = tokenize_input_prompts(ctx_server.vocab, ctx_server.mctx, prompt, true, true);
auto tokenized_prompts = tokenize_input_prompts(ctx_server.vocab, ctx_server.mctx, prompt, true, true, ctx_server.init_opt);
for (const auto & tokens : tokenized_prompts) {
// this check is necessary for models that do not add BOS token to the input
if (tokens.empty()) {
@@ -5357,7 +5364,7 @@ std::unique_ptr<server_res_generator> server_routes::handle_embeddings_impl(cons
return res;
}
std::unique_ptr<server_res_generator> server_routes::handle_count_tokens(const llama_vocab * vocab, mtmd_context * mctx, const server_http_req & req, task_response_type res_type) {
std::unique_ptr<server_res_generator> server_routes::handle_count_tokens(const llama_vocab * vocab, mtmd_context * mctx, const mtmd_helper_init_opt & init_opt, const server_http_req & req, task_response_type res_type) {
auto res = create_response();
std::vector<raw_buffer> files;
json body = json::parse(req.body);
@@ -5395,7 +5402,7 @@ std::unique_ptr<server_res_generator> server_routes::handle_count_tokens(const l
if (!prompt.is_string()) {
throw std::runtime_error("for mtmd, input prompt must be a string.");
}
n_tokens = process_mtmd_prompt(mctx, prompt.get<std::string>(), files, true).size();
n_tokens = process_mtmd_prompt(mctx, prompt.get<std::string>(), files, init_opt, true).size();
} else {
n_tokens = tokenize_mixed(vocab, prompt, true, true).size();
}
+1 -1
View File
@@ -169,7 +169,7 @@ private:
std::unique_ptr<server_res_generator> handle_slots_restore(const server_http_req & req, int id_slot);
std::unique_ptr<server_res_generator> handle_slots_erase(const server_http_req &, int id_slot);
std::unique_ptr<server_res_generator> handle_embeddings_impl(const server_http_req & req, task_response_type res_type);
std::unique_ptr<server_res_generator> handle_count_tokens(const llama_vocab * vocab, mtmd_context * mctx, const server_http_req & req, task_response_type res_type);
std::unique_ptr<server_res_generator> handle_count_tokens(const llama_vocab * vocab, mtmd_context * mctx, const mtmd_helper_init_opt & init_opt, const server_http_req & req, task_response_type res_type);
// using unique_ptr to allow late initialization of const
std::unique_ptr<const server_context_meta> meta;
+1 -1
View File
@@ -103,7 +103,7 @@ int main(int argc, char ** argv) {
mtmd::bitmap_ptr speaker_bitmap;
if (!params.tts_speaker_file.empty()) {
auto wrapper = mtmd_helper_bitmap_init_from_file(mctx.get(), params.tts_speaker_file.c_str(), false);
auto wrapper = mtmd_helper_bitmap_init_from_file(mctx.get(), params.tts_speaker_file.c_str(), false, mtmd_helper_init_opt_default());
if (!wrapper.bitmap) {
LOG_ERR("failed to load speaker file %s\n", params.tts_speaker_file.c_str());
return 1;
+151 -3
View File
@@ -12,6 +12,107 @@ import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
// Require a blank line between sibling element-like nodes in a Svelte template
// (elements, components, and the {#if} / {#each} / {#await} / {#snippet} /
// {@render} blocks) that sit on separate lines at the same nesting level.
// Whitespace between siblings is a whitespace-only SvelteText node; when it
// holds a single newline (no blank line) the fix adds one, keeping the
// indentation of the second sibling. Real text content (e.g. `foo\n\nbar`)
// is left alone.
const ELEMENT_LIKE_TYPES = new Set([
'SvelteAwaitBlock',
'SvelteComponent',
'SvelteEachBlock',
'SvelteElement',
'SvelteIfBlock',
'SvelteKeyBlock',
'SvelteRenderTag',
'SvelteSelf',
'SvelteSnippetBlock'
]);
const paddingLineBetweenElements = {
create(context) {
// Check one list of template children. Each children array holds the
// element-like nodes plus the whitespace/comment text between them.
function checkChildren(children) {
if (!Array.isArray(children)) return;
let lastElement = null;
let lastWhitespace = null;
for (const child of children) {
if (child.type === 'SvelteText' && /^\s*$/.test(child.value)) {
lastWhitespace = child;
continue;
}
if (!ELEMENT_LIKE_TYPES.has(child.type)) continue;
if (
lastElement &&
lastWhitespace &&
child.loc.start.line - lastElement.loc.end.line === 1
) {
const textNode = lastWhitespace;
context.report({
fix(fixer) {
// Add a second newline so the two siblings are separated by a
// blank line, keeping the trailing indentation.
return fixer.replaceText(textNode, textNode.value.replace(/\n/, '\n\n'));
},
message: 'Expected a blank line between sibling elements.',
node: child
});
}
lastElement = child;
lastWhitespace = null;
}
}
return {
SvelteAwaitBlock(node) {
checkChildren(node.children);
checkChildren(node.then?.children);
checkChildren(node.else?.children);
},
SvelteComponent(node) {
checkChildren(node.children);
},
SvelteEachBlock(node) {
checkChildren(node.children);
checkChildren(node.else?.children);
},
SvelteElement(node) {
checkChildren(node.children);
},
SvelteFragment(node) {
checkChildren(node.children);
},
SvelteIfBlock(node) {
checkChildren(node.children);
checkChildren(node.else?.children);
},
SvelteKeyBlock(node) {
checkChildren(node.children);
},
SvelteProgram(node) {
checkChildren(node.children);
},
SvelteSnippetBlock(node) {
checkChildren(node.children);
}
};
},
meta: {
docs: { description: 'Require a blank line between sibling elements in a Svelte template.' },
fixable: 'whitespace',
schema: [],
type: 'layout'
}
};
// Require a blank line between consecutive class accessors (get/set). The core
// `padding-line-between-statements` rule only handles statements, not class
// members, so this is enforced with a small custom rule.
@@ -66,7 +167,12 @@ export default ts.config(
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
plugins: {
local: { rules: { 'blank-line-between-accessors': blankLineBetweenAccessors } },
local: {
rules: {
'blank-line-between-accessors': blankLineBetweenAccessors,
'padding-line-between-elements': paddingLineBetweenElements
}
},
perfectionist,
'simple-import-sort': simpleImportSort
},
@@ -82,6 +188,8 @@ export default ts.config(
'eol-last': 'error',
// Enforce a blank line between consecutive get/set accessors
'local/blank-line-between-accessors': 'error',
// Require a blank line between sibling elements in a Svelte template
'local/padding-line-between-elements': 'error',
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off',
@@ -156,9 +264,49 @@ export default ts.config(
// grouping); Prettier normalizes comma spacing afterwards.
'simple-import-sort/imports': ['error', { groups: [['.*']] }],
'svelte/no-at-html-tags': 'off',
// This app uses hash-based routing (#/) where resolve() from $app/paths does not apply
'svelte/no-navigation-without-resolve': 'off'
'svelte/no-navigation-without-resolve': 'off',
// Sort HTML attributes alphabetically in the markup. The Svelte directives
// (bind:/use:/animate:/style:/in:/out:/transition:/class:) sort first,
// alphabetically among themselves, then all remaining attributes sort
// alphabetically. The rule keeps spread attributes in place and does not cross
// them. `this` stays first on <svelte:element> because Prettier forces it there
// - reordering it alphabetically would fight the formatter.
'svelte/sort-attributes': [
'error',
{
order: [
'this',
{
match: [
'/^bind:/u',
'/^use:/u',
'/^animate:/u',
'/^style:/u',
'/^in:/u',
'/^out:/u',
'/^transition:/u',
'/^class:/u'
],
sort: 'alphabetical'
},
{
match: [
'!/^bind:/u',
'!/^use:/u',
'!/^animate:/u',
'!/^style:/u',
'!/^in:/u',
'!/^out:/u',
'!/^transition:/u',
'!/^class:/u'
],
sort: 'alphabetical'
}
]
}
]
}
},
{
@@ -41,17 +41,17 @@
{#snippet button(props = {})}
<Button
{...props}
{href}
{variant}
{size}
aria-label={ariaLabel || tooltip}
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
{disabled}
{href}
onclick={(e: MouseEvent) => {
if (stopPropagationOnClick) e.stopPropagation();
onclick?.(e);
}}
class="h-6 w-6 p-0 {className} flex hover:bg-transparent data-[state=open]:bg-transparent!"
aria-label={ariaLabel || tooltip}
{size}
{variant}
>
{#if icon}
{@const IconComponent = icon}
@@ -10,9 +10,9 @@
</script>
<ActionIcon
icon={Copy}
tooltip={ariaLabel}
iconSize={ICON_CLASS_DEFAULT}
disabled={!canCopy}
icon={Copy}
iconSize={ICON_CLASS_DEFAULT}
onclick={() => canCopy && copyToClipboard(text)}
tooltip={ariaLabel}
/>
@@ -108,13 +108,13 @@
{/if}
<DialogChatAttachmentsPreview
bind:open={viewAllDialogOpen}
{activeModelId}
{attachments}
bind:open={viewAllDialogOpen}
{previewFocusIndex}
{uploadedFiles}
/>
{#if mcpResourcePreviewExtra}
<DialogMcpResourcePreview extra={mcpResourcePreviewExtra} bind:open={mcpResourcePreviewOpen} />
<DialogMcpResourcePreview bind:open={mcpResourcePreviewOpen} extra={mcpResourcePreviewExtra} />
{/if}
@@ -75,58 +75,58 @@
{#if mcpPrompt}
<ChatAttachmentsListItemMcpPrompt
class="max-w-[300px] min-w-[200px] flex-shrink-0 {className} {scrollClasses}"
prompt={mcpPrompt}
{readonly}
isLoading={item.isLoading}
loadError={item.loadError}
onRemove={onFileRemove ? () => onFileRemove(item.id) : undefined}
prompt={mcpPrompt}
{readonly}
/>
{/if}
{:else if isMcpResource(item)}
{@const mcpResource = item.attachment as DatabaseMessageExtraMcpResource}
<ChatAttachmentsListItemMcpResource
class="flex-shrink-0 {className} {scrollClasses}"
attachment={toMcpResourceAttachment(mcpResource, item.id)}
class="flex-shrink-0 {className} {scrollClasses}"
onclick={() => onMcpResourcePreview?.(mcpResource)}
/>
{:else if item.isImage && item.preview}
<ChatAttachmentsListItemThumbnailImage
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
height={imageHeight}
id={item.id}
{imageClass}
name={item.name}
onRemove={onFileRemove}
onclick={() => onPreview?.(item)}
preview={item.preview}
{readonly}
onRemove={onFileRemove}
height={imageHeight}
width={imageWidth}
{imageClass}
onclick={() => onPreview?.(item)}
/>
{:else if isPdfFile(item.attachment, item.uploadedFile)}
<ChatAttachmentsListItemThumbnailFile
attachment={item.attachment}
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
id={item.id}
name={item.name}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onclick={() => onPreview?.(item)}
{readonly}
size={item.size}
textContent={item.textContent}
uploadedFile={item.uploadedFile}
/>
{:else}
<ChatAttachmentsListItemThumbnailFile
attachment={item.attachment}
class="flex-shrink-0 cursor-pointer {className} {scrollClasses}"
id={item.id}
name={item.name}
size={item.size}
{readonly}
onRemove={onFileRemove}
textContent={item.textContent}
attachment={item.attachment}
uploadedFile={item.uploadedFile}
onclick={() => onPreview?.(item)}
{readonly}
size={item.size}
textContent={item.textContent}
uploadedFile={item.uploadedFile}
/>
{/if}
@@ -35,7 +35,7 @@
<div
class="absolute top-10 right-2 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
>
<ActionIcon icon={X} tooltip="Remove" stopPropagationOnClick onclick={() => onRemove?.()} />
<ActionIcon icon={X} onclick={() => onRemove?.()} stopPropagationOnClick tooltip="Remove" />
</div>
{/if}
</div>
@@ -101,7 +101,7 @@
<div
class="absolute top-2 right-2 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
>
<ActionIcon icon={X} tooltip="Remove" stopPropagationOnClick onclick={() => onRemove?.(id)} />
<ActionIcon icon={X} onclick={() => onRemove?.(id)} stopPropagationOnClick tooltip="Remove" />
</div>
{/snippet}
@@ -30,7 +30,7 @@
</script>
{#snippet image()}
<img src={preview} alt={name} class="{height} {width} cursor-pointer object-cover {imageClass}" />
<img alt={name} class="{height} {width} cursor-pointer object-cover {imageClass}" src={preview} />
{/snippet}
<div
@@ -185,30 +185,30 @@
<div class="{className} flex flex-col text-white">
<div class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden">
<ChatAttachmentsPreviewNavButtons onPrev={prev} onNext={next} show={allItems.length > 1} />
<ChatAttachmentsPreviewNavButtons onNext={next} onPrev={prev} show={allItems.length > 1} />
<div class="flex h-full w-full flex-col items-center justify-start overflow-auto py-4">
{#if currentItem}
<ChatAttachmentsPreviewFileInfo {displayName} {fileSize} />
<ChatAttachmentsPreviewCurrentItem
{activeModelId}
{audioSrc}
{currentItem}
{isImage}
{isAudio}
{isVideo}
{isPdf}
{isText}
{displayPreview}
{displayTextContent}
{audioSrc}
{videoSrc}
{language}
{hasVisionModality}
{activeModelId}
{isAudio}
{isImage}
{isPdf}
{isText}
{isVideo}
{language}
{videoSrc}
/>
{/if}
<ChatAttachmentsPreviewThumbnailStrip items={allItems} {currentIndex} {onNavigate} />
<ChatAttachmentsPreviewThumbnailStrip {currentIndex} items={allItems} {onNavigate} />
</div>
</div>
</div>
@@ -53,18 +53,18 @@
{#key currentItem.id}
{#if isPdf}
<ChatAttachmentsPreviewCurrentItemPdf
{activeModelId}
{currentItem}
displayName={currentItem.name}
{displayTextContent}
{hasVisionModality}
{activeModelId}
/>
{:else if isImage}
<ChatAttachmentsPreviewCurrentItemImage {currentItem} {displayPreview} />
{:else if isText && displayTextContent}
<ChatAttachmentsPreviewCurrentItemText {displayTextContent} {language} />
{:else if isAudio}
<ChatAttachmentsPreviewCurrentItemAudio {currentItem} {audioSrc} />
<ChatAttachmentsPreviewCurrentItemAudio {audioSrc} {currentItem} />
{:else if isVideo}
<ChatAttachmentsPreviewCurrentItemVideo {currentItem} {videoSrc} />
{:else if isUnavailable}
@@ -14,7 +14,7 @@
<Music class="mx-auto mb-4 h-16 w-16 text-white/50" />
{#if audioSrc}
<audio controls class="mb-4 w-full" src={audioSrc}>
<audio class="mb-4 w-full" controls src={audioSrc}>
Your browser does not support the audio element.
</audio>
{:else}
@@ -10,9 +10,9 @@
{#if displayPreview}
<div class="flex flex-1 items-center justify-center">
<img
src={displayPreview}
alt={currentItem?.name || 'preview'}
class="max-h-[80vh] max-w-[80vw] rounded-lg object-contain shadow-lg"
src={displayPreview}
/>
</div>
{/if}
@@ -87,20 +87,20 @@
<div class="mb-4 flex items-center justify-end gap-2">
<Button
variant={pdfViewMode === PdfViewMode.TEXT ? 'default' : 'outline'}
size="sm"
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
disabled={pdfImagesLoading}
onclick={() => (pdfViewMode = PdfViewMode.TEXT)}
size="sm"
variant={pdfViewMode === PdfViewMode.TEXT ? 'default' : 'outline'}
>
<FileText class="mr-1 {ICON_CLASS_DEFAULT}" />
Text
</Button>
<Button
variant={pdfViewMode === PdfViewMode.PAGES ? 'default' : 'outline'}
size="sm"
onclick={() => (pdfViewMode = PdfViewMode.PAGES)}
disabled={pdfImagesLoading}
onclick={() => (pdfViewMode = PdfViewMode.PAGES)}
size="sm"
variant={pdfViewMode === PdfViewMode.PAGES ? 'default' : 'outline'}
>
{#if pdfImagesLoading}
<div
@@ -116,7 +116,9 @@
{#if !hasVisionModality && activeModelId && currentItem}
<Alert.Root class="mb-4 max-w-4xl">
<Info class={ICON_CLASS_DEFAULT} />
<Alert.Title>Preview only</Alert.Title>
<Alert.Description>
<span class="inline-flex">
The selected model does not support vision. Only the extracted
@@ -140,6 +142,7 @@
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-white border-t-transparent"
></div>
<p class="text-white/70">Converting PDF to images...</p>
</div>
</div>
@@ -147,20 +150,25 @@
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
<p class="mb-4 text-white/70">Failed to load PDF images</p>
<p class="text-sm text-white/50">{pdfImagesError}</p>
</div>
</div>
{:else if pdfImages.length > 0}
{#each pdfImages as image, index (image)}
<p class="mb-2 text-sm text-white/50">Page {index + 1}</p>
<img src={image} alt="PDF Page {index + 1}" class="mx-auto max-w-[85vw] rounded-lg shadow-lg" />
<img alt="PDF Page {index + 1}" class="mx-auto max-w-[85vw] rounded-lg shadow-lg" src={image} />
<div class="h-4"></div>
{/each}
{:else}
<div class="flex flex-1 items-center justify-center p-8">
<div class="text-center">
<FileText class="mx-auto mb-4 h-16 w-16 text-white/50" />
<p class="text-white/70">No PDF pages available</p>
</div>
</div>
@@ -14,7 +14,7 @@
<Video class="mx-auto mb-4 h-16 w-16 text-white/50" />
{#if videoSrc}
<video controls class="mb-4 w-full" src={videoSrc}>
<video class="mb-4 w-full" controls src={videoSrc}>
<track kind="captions" src="" />
Your browser does not support the video element.
</video>
@@ -13,21 +13,21 @@
{#if show}
<Button
variant="secondary"
size="icon"
aria-label="Previous"
class="absolute top-1/2 left-4 z-10 h-8 w-8 -translate-y-1/2 rounded-full bg-background/5 p-0 text-white!"
onclick={onPrev}
aria-label="Previous"
size="icon"
variant="secondary"
>
<ChevronLeft class="size-4" />
</Button>
<Button
variant="secondary"
size="icon"
aria-label="Next"
class="absolute top-1/2 right-4 z-10 h-8 w-8 -translate-y-1/2 rounded-full bg-background/5 p-0 text-white!"
onclick={onNext}
aria-label="Next"
size="icon"
variant="secondary"
>
<ChevronRight class="size-4" />
</Button>
@@ -38,16 +38,16 @@
{#each items as item, index (item.id)}
<button
{...{ [UI_DATA_ATTRS.THUMBNAIL_INDEX]: index }}
aria-label={`Go to ${item.name}`}
class={[
'relative flex-shrink-0 cursor-pointer overflow-hidden rounded border-2 bg-black/80 backdrop-blur-sm transition-all hover:opacity-90',
index === currentIndex ? 'border-white' : 'border-transparent opacity-60',
'[&:not(:first-child)]:last:mr-4 [&:not(:last-child)]:first:ml-4'
]}
onclick={() => onNavigate(index)}
aria-label={`Go to ${item.name}`}
>
{#if item.isImage && item.preview}
<img src={item.preview} alt={item.name} class="h-12 w-12 object-cover" />
<img alt={item.name} class="h-12 w-12 object-cover" src={item.preview} />
{:else}
<div
class="bg-foreground-muted/50 flex h-12 w-12 flex-col items-center justify-center gap-0.5 py-1"
@@ -8,7 +8,8 @@
ChatFormInputFileInputInvisible,
ChatFormMcpResourcesList,
ChatFormPickers,
DialogMcpResourcesBrowser
DialogMcpResourcesBrowser,
DialogMcpServers
} from '$lib/components/app';
import {
CLIPBOARD_CONTENT_QUOTE_PREFIX,
@@ -183,6 +184,9 @@
let isResourceDialogOpen = $state(false);
let preSelectedResourceUri = $state<string | undefined>(undefined);
// MCP Servers Dialog State
let isMcpServersDialogOpen = $state(false);
let currentConfig = $derived(settingsStore.config);
let pasteLongTextToFileLength = $derived.by(() => {
@@ -537,30 +541,30 @@
>
<ChatFormPickers
bind:this={pickersRef}
isCommandPickerOpen={pickers.isCommandPickerOpen}
commandQuery={pickers.commandQuery}
commands={pickers.availableCommands}
isCommandPickerOpen={pickers.isCommandPickerOpen}
isMentionPickerOpen={pickers.isMentionPickerOpen}
isPromptPickerOpen={pickers.isPromptPickerOpen}
{mentionAnchor}
mentionQuery={pickers.mentionQuery}
onCommandPickerClose={pickers.handleCommandPickerClose}
onCommandSelect={pickers.handleCommandSelect}
isPromptPickerOpen={pickers.isPromptPickerOpen}
promptSearchQuery={pickers.promptSearchQuery}
isMentionPickerOpen={pickers.isMentionPickerOpen}
mentionQuery={pickers.mentionQuery}
{mentionAnchor}
scopePath={pickers.mentionScopePath}
onPromptPickerClose={pickers.handlePromptPickerClose}
onMentionPickerClose={pickers.handleMentionPickerClose}
onMentionOpened={() => inputRef?.focus()}
onMentionPickerClose={pickers.handleMentionPickerClose}
onMentionSelect={handleMentionSelect}
onPromptLoadStart={handlePromptLoadStart}
onPromptLoadComplete={handlePromptLoadComplete}
onPromptLoadError={handlePromptLoadError}
onPromptLoadStart={handlePromptLoadStart}
onPromptPickerClose={pickers.handlePromptPickerClose}
promptSearchQuery={pickers.promptSearchQuery}
scopePath={pickers.mentionScopePath}
/>
<div
bind:this={mentionAnchor}
class="pointer-events-none absolute top-0 right-0 left-0 h-px"
aria-hidden="true"
class="pointer-events-none absolute top-0 right-0 left-0 h-px"
></div>
<div
@@ -570,29 +574,29 @@
data-slot="input-area"
>
<ChatAttachmentsList
{attachments}
bind:uploadedFiles
onFileRemove={handleFileRemove}
limitToSingleRow
class="py-5"
style="scroll-padding: 1rem;"
activeModelId={activeModelId ?? undefined}
{attachments}
class="py-5"
limitToSingleRow
onFileRemove={handleFileRemove}
style="scroll-padding: 1rem;"
/>
<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!"
>
<ChatFormInput
class="px-5 py-1.5 md:pt-0"
bind:this={inputRef}
bind:value
onKeydown={handleKeydown}
class="px-5 py-1.5 md:pt-0"
{disabled}
onInput={() => {
pickers.handleInput();
onValueChange?.(value);
}}
onKeydown={handleKeydown}
onPaste={handlePaste}
{disabled}
{placeholder}
{useRichInput}
/>
@@ -608,22 +612,23 @@
{/if}
<ChatFormActions
class="px-3"
bind:this={chatFormActionsRef}
canSend={canSubmit}
class="px-3"
{disabled}
{isLoading}
isReasoning={chatStore.isReasoning}
{isRecording}
{showAddButton}
{showModelSelector}
{uploadedFiles}
onFileUpload={handleFileUpload}
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
onMcpSettingsClick={() => (isMcpServersDialogOpen = true)}
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ files: uploadedFiles, message: value })}
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
{showAddButton}
{showModelSelector}
{uploadedFiles}
/>
</div>
</div>
@@ -632,21 +637,20 @@
{#if toolsStore.hasEnabledCwdTools}
<ChatFormCurrentWorkingDirectory
directory={cwd}
isOpen={pickers.isWorkingDirectoryPickerOpen}
bind:query={pickers.workingDirectoryQuery}
customAnchor={mentionAnchor}
directory={cwd}
{disabled}
isOpen={pickers.isWorkingDirectoryPickerOpen}
onChange={handleWorkingDirectoryChange}
onClose={pickers.handleWorkingDirectoryClose}
onOpen={pickers.handleWorkingDirectoryOpen}
{disabled}
/>
{/if}
</form>
<DialogMcpResourcesBrowser
bind:open={isResourceDialogOpen}
preSelectedUri={preSelectedResourceUri}
onAttach={(resource: MCPResourceInfo) => {
mcpStore.attachResource(resource.uri);
}}
@@ -655,4 +659,7 @@
preSelectedResourceUri = undefined;
}
}}
preSelectedUri={preSelectedResourceUri}
/>
<DialogMcpServers bind:open={isMcpServersDialogOpen} />
@@ -18,8 +18,8 @@
class="file-upload-button md:h-8 md:w-8 h-9 w-9 rounded-full p-0"
{disabled}
{onclick}
variant="secondary"
type="button"
variant="secondary"
>
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
@@ -1,10 +1,6 @@
<script lang="ts">
import { File, FolderOpen, MessageSquare, Plus, Zap } from '@lucide/svelte';
import {
ChatFormActionAddMcpServersSubmenu,
ChatFormActionAddReasoningSubmenu,
ChatFormActionAddToolsSubmenu
} from '$lib/components/app';
import { File, MessageSquare, Plus } from '@lucide/svelte';
import { ChatFormActionAddToolsSubmenu, McpLogo } from '$lib/components/app';
import { buttonVariants } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -31,11 +27,6 @@
// must not restore focus to the trigger on close
let suppressCloseAutoFocus = false;
function handleMcpSettingsClick() {
dropdownOpen = false;
chatFormActions.onMcpSettingsClick?.();
}
const attachmentMenu = useAttachmentMenu(
() => ({
hasAudioModality: chatFormActions.hasAudioModality,
@@ -93,10 +84,6 @@
}
}}
>
<ChatFormActionAddReasoningSubmenu />
<DropdownMenu.Separator />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<File class={ICON_CLASS_DEFAULT} />
@@ -156,31 +143,14 @@
<ChatFormActionAddToolsSubmenu />
<ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpSettingsClick}
>
<McpLogo class={ICON_CLASS_DEFAULT} />
{#if chatFormActions.hasMcpPromptsSupport}
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpPromptClick}
>
<Zap class={ICON_CLASS_DEFAULT} />
<span>MCP Prompt</span>
</DropdownMenu.Item>
{/if}
{#if chatFormActions.hasMcpResourcesSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpResourcesClick}
>
<FolderOpen class={ICON_CLASS_DEFAULT} />
<span>MCP Resources</span>
</DropdownMenu.Item>
{/if}
<span>MCP Servers</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -1,152 +0,0 @@
<script lang="ts">
import { Plus, Settings } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { DropdownMenuSearchable, McpLogo, McpServerIdentity } from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Switch } from '$lib/components/ui/switch';
import { ICON_CLASS_DEFAULT, ROUTES } from '$lib/constants';
import { HealthCheckStatus } from '$lib/enums';
import { conversationsStore, mcpStore } from '$lib/stores';
import type { MCPServerSettingsEntry } from '$lib/types';
interface Props {
onMcpSettingsClick?: () => void;
}
let { onMcpSettingsClick }: Props = $props();
let mcpSearchQuery = $state('');
// Every configured server is listed; `enabled` is an on/off state,
// not a visibility filter, so a disabled server stays toggleable.
let mcpServers = $derived(mcpStore.getServers());
let hasMcpServers = $derived(mcpServers.length > 0);
let filteredMcpServers = $derived.by(() => {
const query = mcpSearchQuery.toLowerCase().trim();
if (!query) return mcpServers;
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
});
function getServerLabel(server: MCPServerSettingsEntry): string {
return mcpStore.getServerLabel(server);
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.preferences.isMcpServerEnabledForChat(serverId);
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.preferences.toggleMcpServerForChat(serverId);
}
function handleMcpSubMenuOpen(open: boolean) {
if (open) {
mcpSearchQuery = '';
mcpStore.runHealthChecksForServers(mcpServers);
}
}
function handleMcpSettingsClick() {
onMcpSettingsClick?.();
goto(`${hasMcpServers ? '' : '?add'}${ROUTES.MCP_SERVERS}`);
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<McpLogo class={ICON_CLASS_DEFAULT} />
<span>MCP Servers</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-72 pt-0">
{#if hasMcpServers}
<DropdownMenuSearchable
placeholder="Search servers..."
bind:searchValue={mcpSearchQuery}
emptyMessage="No servers found"
isEmpty={filteredMcpServers.length === 0}
>
<div class="max-h-64 overflow-y-auto">
{#each filteredMcpServers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
{@const displayName = getServerLabel(server)}
{@const faviconUrl = mcpStore.getServerFavicon(server.id)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => !hasError && toggleServerForChat(server.id)}
disabled={hasError}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<div class="min-w-0 flex-1">
<McpServerIdentity
{displayName}
{faviconUrl}
iconClass={ICON_CLASS_DEFAULT}
iconRounded="rounded-sm"
showVersion={false}
nameClass="text-sm"
/>
</div>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>
Error
</span>
{/if}
</div>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
/>
</button>
{/each}
</div>
{#snippet footer()}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Settings class={ICON_CLASS_DEFAULT} />
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
{/snippet}
</DropdownMenuSearchable>
{:else}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">
No MCP servers configured
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Plus class={ICON_CLASS_DEFAULT} />
<span>Add MCP Servers</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Root>
@@ -0,0 +1,51 @@
<script lang="ts">
import { FolderOpen, Server, Zap } from '@lucide/svelte';
import { McpLogo } from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts';
const chatFormActions = getChatFormActionsContext();
function handleServersClick() {
chatFormActions.onMcpSettingsClick?.();
}
</script>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<McpLogo class={ICON_CLASS_DEFAULT} />
<span>MCP</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-48">
<DropdownMenu.Item class="flex cursor-pointer items-center gap-2" onclick={handleServersClick}>
<Server class={ICON_CLASS_DEFAULT} />
<span>Servers</span>
</DropdownMenu.Item>
{#if chatFormActions.hasMcpPromptsSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpPromptClick}
>
<Zap class={ICON_CLASS_DEFAULT} />
<span>Prompts</span>
</DropdownMenu.Item>
{/if}
{#if chatFormActions.hasMcpResourcesSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpResourcesClick}
>
<FolderOpen class={ICON_CLASS_DEFAULT} />
<span>Resources</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
@@ -64,6 +64,7 @@
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Maximum reasoning effort with extended context usage</p>
</Tooltip.Content>
@@ -78,7 +78,7 @@
<Sheet.Root bind:open={sheetOpen}>
{@render trigger({ disabled: chatFormActions.disabled, onclick: () => (sheetOpen = true) })}
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0 overflow-y-auto">
<Sheet.Content class="max-h-[85vh] gap-0 overflow-y-auto" side="bottom">
<Sheet.Header>
<Sheet.Title>Add to chat</Sheet.Title>
@@ -90,8 +90,8 @@
<div class="flex flex-col gap-1 px-1.5 pb-2">
{#if reasoning.modelSupportsThinking}
<Collapsible.Root
open={reasoningExpanded}
onOpenChange={(open) => (reasoningExpanded = open)}
open={reasoningExpanded}
>
<Collapsible.Trigger class={sheetItemClass}>
{#if reasoningExpanded}
@@ -120,10 +120,10 @@
{#each reasoning.levels as level (level.value)}
{@const tokenLabel = reasoning.tokenLabel(level)}
<button
type="button"
class={sheetItemRowClass}
class:bg-accent={reasoning.isSelected(level)}
class={sheetItemRowClass}
onclick={() => reasoning.select(level)}
type="button"
>
<div class="flex min-w-0 items-center gap-3">
{#if reasoning.isSelected(level)}
@@ -147,7 +147,7 @@
</Collapsible.Root>
{/if}
<Collapsible.Root open={filesExpanded} onOpenChange={(open) => (filesExpanded = open)}>
<Collapsible.Root onOpenChange={(open) => (filesExpanded = open)} open={filesExpanded}>
<Collapsible.Trigger class={sheetItemClass}>
{#if filesExpanded}
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
@@ -166,9 +166,9 @@
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
{#if enabled}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[item.action]()}
type="button"
>
<item.icon class="{ICON_CLASS_DEFAULT} shrink-0" />
@@ -177,7 +177,7 @@
{:else if item.disabledTooltip}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button type="button" class={sheetItemClass} disabled>
<button class={sheetItemClass} disabled type="button">
<item.icon class="{ICON_CLASS_DEFAULT} shrink-0" />
<span>{item.label}</span>
@@ -194,7 +194,7 @@
</Collapsible.Content>
</Collapsible.Root>
<Collapsible.Root open={mcpExpanded} onOpenChange={(open) => (mcpExpanded = open)}>
<Collapsible.Root onOpenChange={(open) => (mcpExpanded = open)} open={mcpExpanded}>
<Collapsible.Trigger class={sheetItemClass}>
{#if mcpExpanded}
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
@@ -223,21 +223,21 @@
)}
<button
type="button"
class={sheetItemRowClass}
disabled={hasError}
onclick={() =>
!hasError && conversationsStore.preferences.toggleMcpServerForChat(server.id)}
disabled={hasError}
type="button"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={faviconUrl}
/>
{/if}
@@ -270,7 +270,7 @@
</Collapsible.Root>
{#if toolsPanel.totalToolCount > 0}
<Collapsible.Root open={toolsExpanded} onOpenChange={(open) => (toolsExpanded = open)}>
<Collapsible.Root onOpenChange={(open) => (toolsExpanded = open)} open={toolsExpanded}>
<Collapsible.Trigger class={sheetItemClass}>
{#if toolsExpanded}
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
@@ -295,18 +295,18 @@
{@const favicon = toolsPanel.getFavicon(group)}
<button
type="button"
class={sheetItemRowClass}
onclick={() => toolsPanel.toggleGroupByKey(group.key)}
type="button"
>
{#if favicon}
<img
src={favicon}
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}
@@ -319,8 +319,8 @@
<Checkbox
{checked}
class="{ICON_CLASS_DEFAULT} shrink-0"
onclick={(e) => e.stopPropagation()}
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
onclick={(e) => e.stopPropagation()}
/>
</button>
{/each}
@@ -330,9 +330,9 @@
{/if}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
type="button"
>
<MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
@@ -341,9 +341,9 @@
{#if chatFormActions.hasMcpPromptsSupport}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_PROMPT_CLICK]()}
type="button"
>
<Zap class="{ICON_CLASS_DEFAULT} shrink-0" />
@@ -353,9 +353,9 @@
{#if chatFormActions.hasMcpResourcesSupport}
<button
type="button"
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_RESOURCES_CLICK]()}
type="button"
>
<FolderOpen class="{ICON_CLASS_DEFAULT} shrink-0" />
@@ -68,8 +68,8 @@
{@const favicon = toolsPanel.getFavicon(group)}
<Collapsible.Root
open={isExpanded}
onOpenChange={() => toolsPanel.toggleGroupExpanded(group.key)}
open={isExpanded}
>
<div class="flex items-center gap-1">
<Collapsible.Trigger
@@ -84,12 +84,12 @@
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
{#if favicon}
<img
src={favicon}
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}
@@ -107,8 +107,8 @@
<Checkbox
{...props}
{checked}
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
class="mr-2 {ICON_CLASS_DEFAULT} shrink-0"
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
/>
{/snippet}
</Tooltip.Trigger>
@@ -127,14 +127,14 @@
{#each group.tools as entry (entry.key)}
{@const enabled = toolsStore.isToolEnabled(entry.key)}
<button
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50"
onclick={() => toolsStore.toggleTool(entry.key)}
type="button"
>
<span
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
data-slot="checkbox"
data-state={enabled ? 'checked' : 'unchecked'}
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
{#if enabled}
<Check class="size-3.5" />
@@ -139,17 +139,17 @@
{#if deviceStore.isMobile}
<ModelsSelectorSheet
disabled={disabled || isOffline}
bind:this={selectorModelRef}
currentModel={selectorModel}
disabled={disabled || isOffline}
{forceForegroundText}
{useGlobalSelection}
/>
{:else}
<ModelsSelectorDropdown
disabled={disabled || isOffline}
bind:this={selectorModelRef}
currentModel={selectorModel}
disabled={disabled || isOffline}
{forceForegroundText}
{useGlobalSelection}
/>
@@ -17,16 +17,17 @@
{#snippet submitButton(props = {})}
<Button
type="submit"
disabled={isDisabled}
class={[
'md:h-8 md:w-8 h-9 w-9 rounded-full p-0',
showErrorState &&
'bg-red-400/10 text-red-400 hover:bg-red-400/20 hover:text-red-400 disabled:opacity-100'
]}
disabled={isDisabled}
type="submit"
{...props}
>
<span class="sr-only">Send</span>
<ArrowUp class="h-12 w-12" />
</Button>
{/snippet}
@@ -1,6 +1,5 @@
<script lang="ts">
import { SkipForward, Square } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
ChatFormActionModels,
@@ -10,7 +9,7 @@
ChatFormContextGauge
} from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT, ROUTES } from '$lib/constants';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { setChatFormActionsContext } from '$lib/contexts';
import { FileTypeCategory, MessageRole } from '$lib/enums';
import { ChatService } from '$lib/services';
@@ -34,6 +33,7 @@
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
onMcpSettingsClick?: () => void;
}
let {
@@ -47,6 +47,7 @@
onFileUpload,
onMcpPromptClick,
onMcpResourcesClick,
onMcpSettingsClick,
onMicClick,
onStop,
onSystemPromptClick,
@@ -163,7 +164,7 @@
return onMcpResourcesClick;
},
get onMcpSettingsClick() {
return () => goto(ROUTES.MCP_SERVERS);
return onMcpSettingsClick;
},
get onSystemPromptClick() {
return onSystemPromptClick;
@@ -188,14 +189,14 @@
{#if showModelSelector}
<ChatFormActionModels
{disabled}
bind:this={selectorModelRef}
bind:hasAudioModality
bind:hasModelSelected
bind:hasVideoModality
bind:hasVisionModality
bind:hasModelSelected
bind:isSelectedModelInCache
bind:submitTooltip
bind:this={selectorModelRef}
{disabled}
forceForegroundText
useGlobalSelection
/>
@@ -204,12 +205,12 @@
{#if isReasoning}
<Button
type="button"
variant="secondary"
class="group h-8 w-8 rounded-full p-0"
onclick={() =>
ChatService.stopReasoning(activeMessage?.completionId ?? '', activeMessage?.model)}
class="group h-8 w-8 rounded-full p-0"
title="Skip reasoning"
type="button"
variant="secondary"
>
<span class="sr-only">Skip reasoning</span>
@@ -221,10 +222,10 @@
{#if isLoading && !canSubmit}
<Button
class="group h-8 w-8 rounded-full p-0 hover:bg-destructive/10!"
onclick={onStop}
type="button"
variant="secondary"
onclick={onStop}
class="group h-8 w-8 rounded-full p-0 hover:bg-destructive/10!"
>
<span class="sr-only">Stop</span>
@@ -238,8 +239,8 @@
<ChatFormActionSubmit
canSend={canSend && (showModelSelector ? hasModelSelected && isSelectedModelInCache : true)}
{disabled}
tooltipLabel={submitTooltip}
showErrorState={showModelSelector && hasModelSelected && !isSelectedModelInCache}
tooltipLabel={submitTooltip}
/>
{/if}
</div>
@@ -42,16 +42,16 @@
</script>
<div
role="button"
tabindex="0"
aria-label="Context usage"
data-context-gauge-trigger
class="flex h-5 w-5 cursor-default items-center justify-center"
data-context-gauge-trigger
onclick={gaugeTriggerClick}
onkeydown={gaugeTriggerKeydown}
onpointerdown={gaugeTriggerPointerDown}
onpointerenter={gaugeTriggerEnter}
onpointerleave={gaugeTriggerLeave}
role="button"
tabindex="0"
>
<ContextGaugeDial percent={gauge.contextPercent} level={gauge.colorLevel} />
<ContextGaugeDial level={gauge.colorLevel} percent={gauge.contextPercent} />
</div>
@@ -11,6 +11,7 @@
<div class="grid gap-1.5">
<div class="flex items-baseline justify-between">
<span class="text-muted-foreground">{label}</span>
<span class="font-mono text-muted-foreground">{value}</span>
</div>
@@ -57,12 +57,13 @@
{#if cumulativeRead > 0}
<ContextGaugeDetailRow
label="Prompt tokens evaluated"
value={`${cumulativeRead.toLocaleString()} tok`}
subtitle={cumulativeCacheTotal > 0
? `${cumulativeCacheTotal.toLocaleString()} reused from KV cache`
: undefined}
value={`${cumulativeRead.toLocaleString()} tok`}
/>
{/if}
{#if cumulativeOutput > 0}
<ContextGaugeDetailRow
label="Tokens generated"
@@ -83,10 +84,10 @@
{#if currentRead > 0}
<ContextGaugeDetailRow
label="Prompt"
value={`${currentRead.toLocaleString()} tok`}
subtitle={currentCache > 0
? `${currentFresh.toLocaleString()} fresh + ${currentCache.toLocaleString()} cached`
: undefined}
value={`${currentRead.toLocaleString()} tok`}
/>
{/if}
@@ -100,6 +101,7 @@
<div class="pt-1 mt-0.5 border-t border-border/30">
<div class="flex justify-between">
<span class="text-muted-foreground">KV cache total</span>
<span class="font-mono font-medium">{kvTotal.toLocaleString()} tok</span>
</div>
</div>
@@ -18,7 +18,7 @@
const strokeWidth = $derived(size === 'md' ? 4 : 3);
</script>
<svg viewBox="0 0 32 32" fill="none" class={dimensions}>
<svg class={dimensions} fill="none" viewBox="0 0 32 32">
<circle
cx="16"
cy="16"
@@ -29,15 +29,15 @@
/>
<circle
class="transition-colors duration-300 {strokeLevelClass}"
cx="16"
cy="16"
r={RADIUS}
class="transition-colors duration-300 {strokeLevelClass}"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-dasharray={CIRCUMFERENCE}
stroke-dashoffset={percent !== null ? CIRCUMFERENCE * (1 - percent / 100) : CIRCUMFERENCE}
stroke-linecap="round"
stroke-width={strokeWidth}
transform="rotate(-90 16 16)"
/>
</svg>
@@ -14,11 +14,13 @@
{#if modelId !== null && !isLoading}
<div class="flex flex-col gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
<span>Available context size is only visible once the model is loaded.</span>
<Button size="sm" variant="secondary" class="self-start" onclick={onLoad}>Load model</Button>
<Button class="self-start" onclick={onLoad} size="sm" variant="secondary">Load model</Button>
</div>
{:else if isLoading}
<div class="flex items-center gap-2 border-t border-border/50 pt-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
<span>Loading model...</span>
</div>
{/if}
@@ -54,17 +54,19 @@
{#if gaugePopup.open}
<div
role="status"
bind:this={cardEl}
class="absolute z-50 w-64 -translate-x-1/2 rounded-lg border border-border/50 bg-popover p-3 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/10"
style="left: {gaugePopup.centerX}px; bottom: {gaugePopup.bottom}px"
onpointerenter={gaugeCardEnter}
onpointerleave={gaugeCardLeave}
role="status"
style="left: {gaugePopup.centerX}px; bottom: {gaugePopup.bottom}px"
>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="font-medium">Context</span>
<span class="text-muted-foreground">·</span>
<span class="font-mono text-muted-foreground">
{formatParameters(gauge.contextUsed)}
/ {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
@@ -73,8 +75,8 @@
{#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
<ContextGaugeLoadModel
modelId={gauge.activeModelId}
isLoading={gauge.isActiveModelLoading}
modelId={gauge.activeModelId}
onLoad={gauge.loadModel}
/>
{:else if showProgressBar}
@@ -91,6 +93,7 @@
<span>
<span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
</span>
<span>
{formatParameters(gauge.contextAvailable ?? 0)} remaining
</span>
@@ -101,15 +104,15 @@
{#if gauge.hasAnyUsage}
<ContextGaugeDetails
currentRead={gauge.currentRead}
currentFresh={gauge.currentFresh}
currentCache={gauge.currentCache}
currentOutput={gauge.currentOutput}
kvTotal={gauge.kvTotal}
cumulativeRead={gauge.cumulativeRead}
cumulativeOutput={gauge.cumulativeOutput}
cumulativeCacheTotal={gauge.cumulativeCacheTotal}
averageTokensPerSecond={gauge.averageTokensPerSecond}
cumulativeCacheTotal={gauge.cumulativeCacheTotal}
cumulativeOutput={gauge.cumulativeOutput}
cumulativeRead={gauge.cumulativeRead}
currentCache={gauge.currentCache}
currentFresh={gauge.currentFresh}
currentOutput={gauge.currentOutput}
currentRead={gauge.currentRead}
kvTotal={gauge.kvTotal}
transientDetails={gauge.transientDetails}
/>
{/if}
@@ -323,80 +323,81 @@
</script>
<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
]}
onclick={onOpen}
{disabled}
onclick={onOpen}
type="button"
>
<ChatFormCurrentWorkingDirectoryChip
{directory}
{homeBase}
{disabled}
{showTooltip}
{homeBase}
onClear={handleDismiss}
{showTooltip}
/>
</button>
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
<Popover.Root onOpenChange={handleOpenChange} open={isOpen}>
<Popover.Trigger
aria-hidden="true"
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"
{customAnchor}
onCloseAutoFocus={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
onkeydown={handleKeydown}
preventScroll={false}
side="top"
sideOffset={12}
>
<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"
onClose={closePicker}
placeholder="Choose working directory"
/>
{#if !fileSearchEnabled}
<div class="px-2 py-1.5 text-sm text-muted-foreground">{searchUnavailableMessage}</div>
{:else if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
<ChatFormCurrentWorkingDirectoryResultsList
results={queryResults}
bind:container={listContainer}
error={searchError}
hoveredIndex={nav.hoveredIndex}
isSearching={search.isSearching}
error={searchError}
rawQuery={query}
bind:container={listContainer}
onCommit={commit}
onHover={(index) => nav.setHover(index)}
rawQuery={query}
results={queryResults}
/>
{/if}
{#if pickerSupported && fileSearchEnabled}
<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}
type="button"
>
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
<span>Browse</span>
</button>
{/if}
{#if homeBase && fileSearchEnabled}
<div class="-mx-2 my-2 h-px bg-border/20" aria-hidden="true"></div>
<div aria-hidden="true" class="-mx-2 my-2 h-px bg-border/20"></div>
<span class="px-2 py-1.5 font-mono text-[10px]">
Searching in:
@@ -29,8 +29,8 @@
</script>
<span
class="text-muted-foreground inline-flex items-center gap-1 text-xs group"
class:text-foreground={directory}
class="text-muted-foreground inline-flex items-center gap-1 text-xs group"
>
<div class="flex min-w-0 items-center gap-1 cursor-pointer">
<Folder class="w-3.5 h-3.5" />
@@ -42,6 +42,7 @@
<span {...props} class="max-w-64 truncate">{displayLabel}</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{displayLabelTitle}</p>
</Tooltip.Content>
@@ -56,14 +57,14 @@
class="w-0 overflow-hidden opacity-0 transition-[width,opacity] duration-200 ease-out group-hover:w-auto group-hover:opacity-100"
>
<ActionIcon
icon={X}
tooltip="Reset working directory"
ariaLabel="Reset working directory"
{disabled}
onclick={onClear}
iconSize="h-3 w-3"
stopPropagationOnClick
class="!h-4 !w-4 shrink-0 text-muted-foreground hover:text-foreground"
{disabled}
icon={X}
iconSize="h-3 w-3"
onclick={onClear}
stopPropagationOnClick
tooltip="Reset working directory"
/>
</div>
{/if}
@@ -34,8 +34,8 @@
<div
bind:this={container}
class="max-h-48 overflow-y-auto py-2"
transition:fly={{ duration: FLY_DURATION_MS, y: FLY_Y_PX }}
class="max-h-48 overflow-y-auto py-2"
>
{#if isSearching && results.length === 0}
<div class="px-2 py-1.5 text-sm text-muted-foreground">Searching...</div>
@@ -48,14 +48,15 @@
<button
type="button"
{...{ [UI_DATA_ATTRS.RESULT_INDEX]: index }}
data-highlighted={index === hoveredIndex ? '' : undefined}
class={cn(
'relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground'
)}
data-highlighted={index === hoveredIndex ? '' : undefined}
onclick={() => onCommit?.(path)}
onmouseenter={() => onHover?.(index)}
>
<Folder class="size-4 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate font-mono text-left">
{#each highlightMatch(path, rawQuery.trim()) as seg, segIndex (segIndex)}
{#if seg.match}
@@ -56,23 +56,23 @@
{#if useRichInput}
<ChatFormInputRich
bind:this={richRef}
bind:value
class={className}
{disabled}
{onInput}
{onKeydown}
{onPaste}
{placeholder}
bind:value
/>
{:else}
<ChatFormInputBasic
bind:this={basicRef}
bind:value
class={className}
{disabled}
{onInput}
{onKeydown}
{onPaste}
{placeholder}
bind:value
/>
{/if}
@@ -69,14 +69,14 @@
'text-md min-h-12 w-full resize-none border-0 bg-transparent p-0 leading-6 outline-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0',
disabled && 'cursor-not-allowed'
]}
style="max-height: var(--max-message-height);"
{disabled}
onkeydown={onKeydown}
oninput={(event) => {
autoResizeTextarea(event.currentTarget);
onInput?.();
}}
onkeydown={onKeydown}
onpaste={onPaste}
{placeholder}
style="max-height: var(--max-message-height);"
></textarea>
</div>
@@ -24,8 +24,8 @@
<input
bind:this={fileInputElement}
type="file"
class="hidden {className}"
{multiple}
onchange={handleFileSelect}
class="hidden {className}"
type="file"
/>
@@ -808,25 +808,25 @@
<div class="flex-1 {className} mb-0.5">
<div
bind:this={rootElement}
contenteditable={!disabled}
role="textbox"
aria-multiline="true"
aria-disabled={disabled}
aria-multiline="true"
aria-placeholder={placeholder}
data-placeholder={placeholder}
tabindex={disabled ? -1 : 0}
class={[
'chat-form-input-rich text-md min-h-12 w-full overflow-y-auto 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}
contenteditable={!disabled}
data-placeholder={placeholder}
oncompositionend={handleCompositionEnd}
oncompositionstart={handleCompositionStart}
oncopy={handleCopy}
oncut={handleCut}
oninput={handleInput}
onkeydown={handleKeydown}
onpaste={handlePaste}
oncopy={handleCopy}
oncut={handleCut}
role="textbox"
style="max-height: var(--max-message-height);"
tabindex={disabled ? -1 : 0}
></div>
</div>
@@ -27,8 +27,8 @@
<ScrollCarousel gapSize="2" variant={ScrollCarouselVariant.CENTER}>
{#each attachments as attachment, i (attachment.id)}
<ChatAttachmentsListItemMcpResource
class={i === 0 ? 'ml-3' : ''}
{attachment}
class={i === 0 ? 'ml-3' : ''}
onRemove={handleRemove}
onclick={() => handleResourceClick(attachment.resource.uri)}
/>
@@ -21,12 +21,12 @@
<div class="mb-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={faviconUrl}
/>
{/if}
@@ -1,4 +1,4 @@
<script lang="ts" generics="T">
<script generics="T" lang="ts">
import { SearchInput } from '$lib/components/app';
import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
import { CHAT_FORM_POPOVER_MAX_HEIGHT, UI_DATA_ATTRS } from '$lib/constants';
@@ -67,11 +67,11 @@
{#if showSearchInput}
<div class="absolute top-0 right-0 left-0 z-10 p-2 pb-0">
<SearchInput
{autofocus}
placeholder={searchPlaceholder}
bind:value={searchQuery}
bind:ref={inputRef}
bind:value={searchQuery}
{autofocus}
onClose={onSearchClose}
placeholder={searchPlaceholder}
/>
</div>
{/if}
@@ -85,8 +85,10 @@
{#each { length: skeletonCount } as _, rowIndex (rowIndex)}
<div class="flex items-start gap-3 rounded-lg px-3 py-2">
<div class="mt-0.5 size-4 shrink-0 animate-pulse rounded-md bg-muted/60"></div>
<div class="flex min-w-0 flex-1 flex-col">
<div class="h-5 w-2/5 animate-pulse rounded-sm bg-muted/60"></div>
<div class="h-4 w-1/3 animate-pulse rounded-sm bg-muted/40"></div>
</div>
</div>
@@ -24,11 +24,11 @@
</script>
<button
type="button"
{...{ [UI_DATA_ATTRS.PICKER_INDEX]: dataIndex }}
{disabled}
{onclick}
{onmouseenter}
type="button"
{...{ [UI_DATA_ATTRS.PICKER_INDEX]: dataIndex }}
class="flex w-full cursor-pointer items-start gap-3 rounded-lg px-3 py-2 text-left hover:bg-accent/50 {isSelected
? 'bg-accent/50'
: ''} {disabled ? 'cursor-not-allowed opacity-50' : ''} {className}"
@@ -12,6 +12,7 @@
<!-- Server label skeleton -->
<div class="mb-2 flex items-center gap-1.5">
<div class="h-3 w-3 shrink-0 animate-pulse rounded-sm bg-muted"></div>
<div class="h-3 w-24 animate-pulse rounded bg-muted"></div>
</div>
@@ -30,21 +30,21 @@
}}
>
<Popover.Trigger
aria-hidden="true"
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
aria-hidden="true"
>
<span class="sr-only">{srLabel}</span>
</Popover.Trigger>
<Popover.Content
side="top"
align="start"
sideOffset={12}
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}"
preventScroll={false}
onkeydown={onKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
onkeydown={onKeydown}
preventScroll={false}
side="top"
sideOffset={12}
>
{@render children()}
</Popover.Content>
@@ -104,34 +104,36 @@
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open command picker"
{onClose}
onKeydown={handleKeydown}
srLabel="Open command picker"
>
<ChatFormPickerList
items={filteredCommands}
emptyMessage="No matching command"
isLoading={false}
itemKey={(command) => command.name}
items={filteredCommands}
scrollTrigger={nav.scrollTrigger}
searchQuery={query ?? ''}
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}
{isSelected}
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>
@@ -359,9 +359,9 @@
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open prompt picker"
{onClose}
onKeydown={handleKeydown}
srLabel="Open prompt picker"
>
{#if selectedPrompt}
{@const prompt = selectedPrompt}
@@ -370,10 +370,10 @@
<div class="p-4">
<ChatFormPickerItemHeader
description={prompt.description}
{server}
{serverLabel}
title={prompt.title || prompt.name}
description={prompt.description}
>
{#snippet titleExtra()}
{#if prompt.arguments?.length}
@@ -385,33 +385,33 @@
</ChatFormPickerItemHeader>
<ChatFormPromptPickerArgumentForm
prompt={selectedPrompt}
{promptArgs}
{suggestions}
{loadingSuggestions}
{activeAutocomplete}
{autocompleteIndex}
{promptError}
onArgInput={handleArgInput}
onArgKeydown={handleArgKeydown}
{loadingSuggestions}
onArgBlur={handleArgBlur}
onArgFocus={handleArgFocus}
onArgInput={handleArgInput}
onArgKeydown={handleArgKeydown}
onCancel={handleCancelArgumentForm}
onSelectSuggestion={selectSuggestion}
onSubmit={handleArgumentSubmit}
onCancel={handleCancelArgumentForm}
prompt={selectedPrompt}
{promptArgs}
{promptError}
{suggestions}
/>
</div>
{:else}
<ChatFormPickerList
items={filteredPrompts}
{isLoading}
{selectedIndex}
bind:searchQuery={internalSearchQuery}
{showSearchInput}
searchPlaceholder="Search prompts..."
emptyMessage="No MCP prompts available"
{isLoading}
itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
items={filteredPrompts}
{scrollTrigger}
searchPlaceholder="Search prompts..."
{selectedIndex}
{showSearchInput}
>
{#snippet item(prompt, index, isSelected)}
{@const server = serverSettingsMap.get(prompt.serverName)}
@@ -423,10 +423,10 @@
onclick={() => handlePromptClick(prompt)}
>
<ChatFormPickerItemHeader
description={prompt.description}
{server}
{serverLabel}
title={prompt.title || prompt.name}
description={prompt.description}
>
{#snippet titleExtra()}
{#if prompt.arguments?.length}
@@ -440,7 +440,7 @@
{/snippet}
{#snippet skeleton()}
<ChatFormPickerListItemSkeleton titleWidth="w-32" showBadge />
<ChatFormPickerListItemSkeleton showBadge titleWidth="w-32" />
{/snippet}
</ChatFormPickerList>
{/if}
@@ -38,20 +38,20 @@
}: Props = $props();
</script>
<form onsubmit={onSubmit} class="space-y-3 pt-4">
<form class="space-y-3 pt-4" onsubmit={onSubmit}>
{#each prompt.arguments ?? [] as arg (arg.name)}
<ChatFormPromptPickerArgumentInput
argument={arg}
value={promptArgs[arg.name] ?? ''}
suggestions={suggestions[arg.name] ?? []}
isLoadingSuggestions={loadingSuggestions[arg.name] ?? false}
isAutocompleteActive={activeAutocomplete === arg.name}
autocompleteIndex={activeAutocomplete === arg.name ? autocompleteIndex : 0}
onInput={(value) => onArgInput(arg.name, value)}
onKeydown={(e) => onArgKeydown(e, arg.name)}
isAutocompleteActive={activeAutocomplete === arg.name}
isLoadingSuggestions={loadingSuggestions[arg.name] ?? false}
onBlur={() => onArgBlur(arg.name)}
onFocus={() => onArgFocus(arg.name)}
onInput={(value) => onArgInput(arg.name, value)}
onKeydown={(e) => onArgKeydown(e, arg.name)}
onSelectSuggestion={(value) => onSelectSuggestion(arg.name, value)}
suggestions={suggestions[arg.name] ?? []}
value={promptArgs[arg.name] ?? ''}
/>
{/each}
@@ -67,7 +67,7 @@
{/if}
<div class="mt-8 flex justify-end gap-2">
<Button type="button" size="sm" onclick={onCancel} variant="secondary">Cancel</Button>
<Button onclick={onCancel} size="sm" type="button" variant="secondary">Cancel</Button>
<Button size="sm" type="submit">Use Prompt</Button>
</div>
@@ -36,7 +36,7 @@
</script>
<div class="relative grid gap-1">
<Label for="arg-{argument.name}" class="mb-1 text-muted-foreground">
<Label class="mb-1 text-muted-foreground" for="arg-{argument.name}">
<span>
{argument.name}
@@ -51,30 +51,30 @@
</Label>
<Input
autocomplete="off"
id="arg-{argument.name}"
type="text"
{value}
oninput={(e) => onInput(e.currentTarget.value)}
onkeydown={onKeydown}
onblur={onBlur}
onfocus={onFocus}
oninput={(e) => onInput(e.currentTarget.value)}
onkeydown={onKeydown}
placeholder={argument.description || argument.name}
required={argument.required}
autocomplete="off"
type="text"
{value}
/>
{#if isAutocompleteActive && suggestions.length > 0}
<div
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
transition:fly={{ duration: 100, y: -5 }}
class="absolute top-full right-0 left-0 z-10 mt-1 max-h-32 overflow-y-auto rounded-lg border border-border/50 bg-background shadow-lg"
>
{#each suggestions as suggestion, i (suggestion)}
<button
type="button"
onmousedown={() => onSelectSuggestion(suggestion)}
class="w-full px-3 py-1.5 text-left text-sm hover:bg-accent {i === autocompleteIndex
? 'bg-accent'
: ''}"
onmousedown={() => onSelectSuggestion(suggestion)}
type="button"
>
{suggestion}
</button>
@@ -187,10 +187,10 @@
</script>
<Popover.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
open={isOpen}
>
<!-- Invisible form-wide trigger: stops bits-ui's outside-click detector
from closing the picker when the user clicks inside the textarea.
@@ -198,36 +198,36 @@
(tabindex=-1 + pointer-events-none + opacity-0 + aria-hidden).
Positioning comes from `customAnchor` at the form's top edge. -->
<Popover.Trigger
aria-hidden="true"
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
aria-hidden="true"
>
<span class="sr-only">Open file mention picker</span>
</Popover.Trigger>
<Popover.Content
align="start"
side="top"
sideOffset={12}
{customAnchor}
preventScroll={false}
onkeydown={handleKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
class={[
'w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl',
className
]}
{customAnchor}
onCloseAutoFocus={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
onkeydown={handleKeydown}
preventScroll={false}
side="top"
sideOffset={12}
>
<ChatFormPickerList
items={displayedItems}
{emptyMessage}
isLoading={search.isSearching}
itemKey={(entry) => entry.type + ':' + entry.path}
items={displayedItems}
scrollTrigger={nav.scrollTrigger}
searchQuery={query ?? ''}
selectedIndex={nav.hoveredIndex}
showSearchInput={false}
searchQuery={query ?? ''}
{emptyMessage}
itemKey={(entry) => entry.type + ':' + entry.path}
scrollTrigger={nav.scrollTrigger}
>
{#snippet item(entry, index, isSelected)}
<ChatFormPickerListItem
@@ -245,6 +245,7 @@
: 'text-muted-foreground'
]}
/>
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-w-0 items-center gap-2">
{#if showTooltip}
@@ -254,6 +255,7 @@
<span {...props} class="truncate text-sm font-medium">{entry.name}</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{entry.path}</p>
</Tooltip.Content>
@@ -261,14 +263,16 @@
{:else}
<span class="truncate text-sm font-medium">{entry.name}</span>
{/if}
<span
class="shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-muted-foreground"
>
{entry.type}
</span>
</div>
<span class="min-w-0 flex-1 truncate font-mono text-left text-xs">
<HighlightedMatch text={abbreviateHome(entry.path, home)} query={trimmedQuery} />
<HighlightedMatch query={trimmedQuery} text={abbreviateHome(entry.path, home)} />
</span>
</div>
</ChatFormPickerListItem>
@@ -79,30 +79,30 @@
<ChatFormPickerCommand
bind:this={commandPickerRef}
isOpen={isCommandPickerOpen ?? false}
query={commandQuery ?? ''}
{commands}
isOpen={isCommandPickerOpen ?? false}
onClose={onCommandPickerClose ?? (() => {})}
onSelect={onCommandSelect ?? (() => {})}
query={commandQuery ?? ''}
/>
<ChatFormPickerMcpPrompts
bind:this={promptPickerRef}
isOpen={isPromptPickerOpen}
searchQuery={promptSearchQuery}
onClose={onPromptPickerClose}
{onPromptLoadStart}
{onPromptLoadComplete}
{onPromptLoadError}
{onPromptLoadStart}
searchQuery={promptSearchQuery}
/>
<ChatFormPickerMention
bind:this={mentionPickerRef}
isOpen={isMentionPickerOpen ?? false}
query={mentionQuery ?? ''}
customAnchor={mentionAnchor}
scopePath={scopePath ?? null}
isOpen={isMentionPickerOpen ?? false}
onClose={onMentionPickerClose ?? (() => {})}
onOpened={onMentionOpened}
onSelect={onMentionSelect ?? (() => {})}
query={mentionQuery ?? ''}
scopePath={scopePath ?? null}
/>
@@ -381,13 +381,13 @@
}
</script>
<div class="chat-message" class:chat-message--synthetic={isSynthetic}>
<div class:chat-message--synthetic={isSynthetic} class="chat-message">
{#if message.role === MessageRole.SYSTEM}
<ChatMessageSystem bind:textareaElement class={className} {message} />
{:else if mcpPromptExtra}
<ChatMessageMcpPrompt class={className} {message} mcpPrompt={mcpPromptExtra} />
<ChatMessageMcpPrompt class={className} mcpPrompt={mcpPromptExtra} {message} />
{:else if isSynthetic}
<ChatMessageSynthetic {message} class={className} />
<ChatMessageSynthetic class={className} {message} />
{:else if message.role === MessageRole.USER}
<ChatMessageUser class={className} {isLastUserMessage} {message} {nextAssistantMessage} />
{:else}
@@ -396,9 +396,9 @@
class={className}
{isLastAssistantMessage}
{message}
{toolMessages}
onContinue={handleContinue}
onRegenerate={handleRegenerate}
{toolMessages}
/>
{/if}
</div>
@@ -126,16 +126,16 @@
<div
bind:this={assistantEl}
class="chat-message-assistant text-md group w-full leading-7.5 {className}"
style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
style:--last-user-message-height={lastUserMessageHeight > 0
? `${lastUserMessageHeight}px`
: undefined}
style:--assistant-margin-top={assistantMarginTop > 0 ? `${assistantMarginTop}px` : undefined}
role="group"
aria-label="Assistant message with actions"
class="chat-message-assistant text-md group w-full leading-7.5 {className}"
role="group"
>
{#if showProcessingInfoTop}
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="top" />
<ChatMessageAssistantProcessingInfo {modelLoadingText} position="top" {processingState} />
{/if}
{#if editCtx.isEditing}
@@ -145,16 +145,16 @@
<ChatMessageAssistantRawOutput {message} {toolMessages} />
{:else}
<ChatMessageAgenticContent
{isLastAssistantMessage}
isStreaming={chatStore.isStreaming()}
{message}
{toolMessages}
isStreaming={chatStore.isStreaming()}
{isLastAssistantMessage}
/>
{/if}
{/if}
{#if showProcessingInfoBottom}
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="bottom" />
<ChatMessageAssistantProcessingInfo {modelLoadingText} position="bottom" {processingState} />
{/if}
{#if displayedModel}
@@ -168,8 +168,8 @@
/>
<ChatMessageAssistantStatistics
{message}
isLoading={chatStore.isLoading}
{message}
{processingState}
showMessageStats={currentConfig.showMessageStats}
/>
@@ -179,14 +179,14 @@
{#if message.timestamp && !editCtx.isEditing}
<ChatMessageActionIcons
role={MessageRole.ASSISTANT}
justify="start"
actionsPosition="left"
{onRegenerate}
justify="start"
onContinue={currentConfig.enableContinueGeneration ? onContinue : undefined}
showRawOutputSwitch={currentConfig.showRawOutputSwitch}
rawOutputEnabled={showRawOutput}
onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
{onRegenerate}
rawOutputEnabled={showRawOutput}
role={MessageRole.ASSISTANT}
showRawOutputSwitch={currentConfig.showRawOutputSwitch}
/>
{/if}
</div>
@@ -13,7 +13,7 @@
const marginClass = $derived(position === 'top' ? 'mt-6' : 'mt-4');
</script>
<div class="{marginClass} w-full max-w-3xl" in:fade>
<div in:fade class="{marginClass} w-full max-w-3xl">
<div class="flex flex-col items-start gap-2">
<span class="shimmer-text text-sm">
{modelLoadingText ??
@@ -24,22 +24,22 @@
{#if showMessageStats && isLiveFlowRoot && liveLlm}
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.GENERATION}
isLive
promptTokens={liveLlm.prompt_n}
promptMs={liveLlm.prompt_ms}
predictedTokens={liveLlm.predicted_n}
mode={ChatMessageStatisticsMode.GENERATION}
predictedMs={liveLlm.predicted_ms}
predictedTokens={liveLlm.predicted_n}
promptMs={liveLlm.prompt_ms}
promptTokens={liveLlm.prompt_n}
/>
{:else if showMessageStats && message.timings && message.timings.predicted_n && message.timings.predicted_ms}
{@const agentic = message.timings.agentic}
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.GENERATION}
promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
agenticTimings={agentic}
mode={ChatMessageStatisticsMode.GENERATION}
predictedMs={agentic ? agentic.llm.predicted_ms : message.timings.predicted_ms}
predictedTokens={agentic ? agentic.llm.predicted_n : message.timings.predicted_n}
promptMs={agentic ? agentic.llm.prompt_ms : message.timings.prompt_ms}
promptTokens={agentic ? agentic.llm.prompt_n : message.timings.prompt_n}
/>
{:else if isLoading && showMessageStats}
{@const liveStats = processingState.getLiveProcessingStats()}
@@ -47,12 +47,12 @@
{#if genStats}
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.GENERATION}
isLive
promptTokens={liveStats?.tokensProcessed}
promptMs={liveStats?.timeMs}
predictedTokens={genStats.tokensGenerated}
mode={ChatMessageStatisticsMode.GENERATION}
predictedMs={genStats.timeMs}
predictedTokens={genStats.tokensGenerated}
promptMs={liveStats?.timeMs}
promptTokens={liveStats?.tokensProcessed}
/>
{/if}
{/if}
@@ -19,10 +19,13 @@
<div class="text-muted-foreground flex items-center gap-2 py-1.5 {className}">
{#if info.path === null}
<FolderX class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
<span class="text-foreground/80 text-sm font-medium">Working directory cleared</span>
{:else}
<Folder class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
<span class="text-foreground/80 text-sm font-medium">Set working directory to&nbsp;</span>
<span class="font-mono text-foreground/90 text-sm break-all" title={info.path}>
{info.display}
</span>
@@ -29,9 +29,9 @@
<ChatMessageEditForm />
{:else}
<ChatMessageMcpPromptContent
class="w-full max-w-[80%]"
prompt={mcpPrompt}
variant={McpPromptVariant.MESSAGE}
class="w-full max-w-[80%]"
/>
{#if message.timestamp}
@@ -99,12 +99,12 @@
<Tooltip.Trigger>
{#if serverFavicon}
<img
src={serverFavicon}
alt=""
class="h-3.5 w-3.5 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={serverFavicon}
/>
{/if}
</Tooltip.Trigger>
@@ -17,7 +17,7 @@
</script>
{#if isCwdChange}
<ChatMessageCwdChange {message} class={className} />
<ChatMessageCwdChange class={className} {message} />
{:else}
<span class="text-muted-foreground block text-sm {className}">{message.content}</span>
{/if}
@@ -83,16 +83,16 @@
{#if editCtx.isEditing}
<div class="w-full max-w-[80%]">
<textarea
style="max-height: var(--max-message-height);"
bind:this={textareaElement}
value={editCtx.editedContent}
class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
onkeydown={handleEditKeydown}
oninput={(e) => {
autoResizeTextarea(e.currentTarget);
editCtx.setContent(e.currentTarget.value);
}}
onkeydown={handleEditKeydown}
placeholder="Edit system message..."
style="max-height: var(--max-message-height);"
value={editCtx.editedContent}
></textarea>
<div class="mt-2 flex justify-end gap-2">
@@ -104,8 +104,8 @@
<Button
class="h-8 px-3"
onclick={editCtx.save}
disabled={!editCtx.editedContent.trim()}
onclick={editCtx.save}
size="sm"
>
<Check class="mr-1 h-3 w-3" />
@@ -34,34 +34,34 @@
</script>
{#if isSearchCall}
<ChatMessageToolCallBlockSearchResults {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockSearchResults {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.BROWSER_GET_DATETIME}
<ChatMessageToolCallBlockGetDatetime {section} {isStreaming} />
<ChatMessageToolCallBlockGetDatetime {isStreaming} {section} />
{:else if section.toolName === BuiltInTool.SERVER_GET_INFO}
<ChatMessageToolCallBlockGetInfo {section} {isStreaming} />
<ChatMessageToolCallBlockGetInfo {isStreaming} {section} />
{:else if section.toolName === BuiltInTool.SERVER_READ_FILE}
<ChatMessageToolCallBlockReadFile {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockReadFile {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.BROWSER_READ_MEDIA}
<ChatMessageToolCallBlockReadMedia {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockReadMedia {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.SERVER_EDIT_FILE}
<ChatMessageToolCallBlockEditFile {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockEditFile {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.SERVER_WRITE_FILE}
<ChatMessageToolCallBlockWriteFile {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockWriteFile {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.SERVER_EXEC_SHELL_COMMAND}
<ChatMessageToolCallBlockExecShellCommand
{section}
{open}
{isStreaming}
{isExecuting}
{attachments}
{isExecuting}
{isStreaming}
{onToggle}
{open}
{section}
/>
{:else if section.toolName === BuiltInTool.SERVER_FILE_GLOB_SEARCH}
<ChatMessageToolCallBlockFileGlobSearch {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockFileGlobSearch {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.SERVER_GREP_SEARCH}
<ChatMessageToolCallBlockGrepSearch {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockGrepSearch {isStreaming} {onToggle} {open} {section} />
{:else if section.toolName === BuiltInTool.BROWSER_RUN_JAVASCRIPT}
<ChatMessageToolCallBlockRunJavascript {section} {open} {isStreaming} {onToggle} />
<ChatMessageToolCallBlockRunJavascript {isStreaming} {onToggle} {open} {section} />
{:else}
<ChatMessageToolCallBlockDefault {section} {open} {isStreaming} {attachments} {onToggle} />
<ChatMessageToolCallBlockDefault {attachments} {isStreaming} {onToggle} {open} {section} />
{/if}
@@ -34,15 +34,17 @@
);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={null} {title} {onToggle}>
<ToolCallBlock {isStreaming} meta={null} {onToggle} {open} {section} {title}>
{#snippet children(_meta, ctx)}
{#if ctx.isStreamingCall}
<div class="mb-2 flex items-center gap-2 text-xs text-muted-foreground/70">
<span>Input</span>
{#if ctx.isStreaming}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
</div>
{#if section.toolArgs}
<SyntaxHighlightedCode
code={formatJsonPretty(section.toolArgs)}
@@ -67,6 +69,7 @@
<div class="mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70">
<span>Input</span>
</div>
<SyntaxHighlightedCode
code={formatJsonPretty(section.toolArgs ?? '')}
language={FileTypeText.JSON}
@@ -74,16 +77,19 @@
streaming={ctx.isCodeStreaming}
/>
{/if}
<div
class={showInput
? 'mt-4 mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70'
: 'mb-1.5 flex items-center gap-2 text-xs text-muted-foreground/70'}
>
<span>Output</span>
{#if ctx.isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
</div>
{#if ctx.isPending}
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">
Waiting for result...
@@ -96,18 +102,19 @@
maxHeight={MAX_HEIGHT_CODE_BLOCK}
/>
{:else if outputKind === ToolResultKind.MARKDOWN}
<MarkdownContent content={section.toolResult} {attachments} />
<MarkdownContent {attachments} content={section.toolResult} />
{:else}
<div class="overflow-auto">
{#each parsedLines as line, i (i)}
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">
{line.text}
</div>
{#if line.media}
{#if line.media.type === AttachmentType.AUDIO}
{@const audioMimeType = line.media.mimeType ?? MimeTypeAudio.MP3_MPEG}
<div class="mt-2 mb-2">
<audio controls class="w-full rounded-lg">
<audio class="w-full rounded-lg" controls>
<source
src={createBase64DataUrl(audioMimeType, line.media.base64Data)}
type={audioMimeType}
@@ -117,10 +124,10 @@
</div>
{:else}
<img
src={line.media.base64Url}
alt={line.media.name}
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
loading="lazy"
src={line.media.base64Url}
/>
{/if}
{/if}
@@ -23,12 +23,14 @@
);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={editFileMeta} {onToggle}>
<ToolCallBlock {isStreaming} meta={editFileMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
<span class="text-muted-foreground">Edit file </span>
<span class="font-mono" title={editFileMeta?.filePath}
>{abbreviateHome(editFileMeta?.filePath ?? '', home)}</span
>
{#if editFileMeta?.errorMessage}
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
{/if}
@@ -40,6 +42,7 @@
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
>
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{meta.errorMessage}</span>
</div>
{:else if meta && meta.edits.length > 0}
@@ -48,13 +51,17 @@
<div class="mb-1.5 text-xs text-muted-foreground/70 italic">
Edit {ei + 1}&nbsp;of&nbsp;{meta.edits.length}
</div>
<div class="diff-block" style:max-height={MAX_HEIGHT_CODE_BLOCK}>
<div style:max-height={MAX_HEIGHT_CODE_BLOCK} class="diff-block">
<div class="diff-pre">
{#each diffLines as line, li (li)}
<div class="diff-line diff-{line.kind}">
<span class="diff-old-num">{line.oldLine ?? ''}</span>
<span class="diff-marker">{prefixFor(line.kind)}</span>
<span class="diff-new-num">{line.newLine ?? ''}</span>
<span class="diff-text">{line.text || ' '}</span>
</div>
{/each}
@@ -62,9 +69,11 @@
</div>
</div>
{/each}
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
{#if meta.resultMessage}
{meta.resultMessage}{meta.editsApplied != null ? RESULT_STAT_SEPARATOR : ''}{/if}
{#if meta.editsApplied != null}
<span class="font-mono">{meta.editsApplied}</span>
{meta.editsApplied === 1 ? 'edit' : 'edits'}&nbsp;applied
@@ -176,6 +176,7 @@
{#snippet execShellTitle()}
{#if cwd}
<span class="exec-wd" title={cwd}>{wdDisplay}</span>
<span class="exec-prompt">$</span>
{/if}
@@ -187,14 +188,14 @@
{/snippet}
<ToolCallBlock
{section}
{open}
extraLiveStreaming={isLive}
{isStreaming}
meta={execShellMeta ? { errorMessage: execShellError } : null}
wrapper={CollapsibleTerminalBlock}
extraLiveStreaming={isLive}
spinIconWhenActive={true}
{onToggle}
{open}
{section}
spinIconWhenActive={true}
wrapper={CollapsibleTerminalBlock}
>
{#snippet titleSnippet()}
{@render execShellTitle()}
@@ -209,23 +210,25 @@
{:else if execShellError}
<div class="flex items-start gap-2 text-xs text-red-600 italic dark:text-red-400">
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{execShellError}</span>
</div>
{:else if section.toolResult}
<div
bind:this={scrollEl}
class="terminal-output"
class:is-clamped={!useFullHeightCodeBlocks}
class="terminal-output"
onscroll={handleScrollEvent}
>
{#each outputLines as line, i (i)}
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{line.text}</div>
{#if line.media?.type === AttachmentType.IMAGE}
<img
src={line.media.base64Url}
alt={line.media.name}
class="mt-2 mb-2 h-auto max-w-full rounded-lg"
loading="lazy"
src={line.media.base64Url}
/>
{/if}
{/each}
@@ -234,14 +237,19 @@
<div class={exitBadgeClass}>
{#if execShellExitStatus.timedOut}
<AlertTriangle class="h-3 w-3" />
<span>timed out</span>
<span class="exit-sep">&middot;</span>
<span>exit {execShellExitStatus.code}</span>
{:else if execShellExitStatus.code === 0}
<Check class="h-3 w-3" />
<span>exit 0</span>
{:else}
<XCircle class="h-3 w-3" />
<span>exit {execShellExitStatus.code}</span>
{/if}
</div>
@@ -19,16 +19,19 @@
const home = $derived(toolsStore.serverHome);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={fileGlobMeta} {onToggle}>
<ToolCallBlock {isStreaming} meta={fileGlobMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
{#if fileGlobMeta}
<span class="text-muted-foreground"
>{fileGlobMeta.include === '**' ? 'List files' : 'Search files'}&nbsp;</span
>
{#if fileGlobMeta.include !== '**'}
<span class="font-mono">{fileGlobMeta.include}</span>
{/if}
<span class="text-muted-foreground">&nbsp;in&nbsp;</span>
<span class="font-mono" title={fileGlobMeta.path}
>{abbreviateHome(fileGlobMeta.path, home)}</span
>
@@ -45,6 +48,7 @@
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
>
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{meta.errorMessage}</span>
</div>
{:else if meta && meta.matches.length > 0}
@@ -53,11 +57,13 @@
<div class="font-mono text-[11px] leading-relaxed whitespace-pre-wrap">{match}</div>
{/each}
</div>
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
Total matches: <span class="font-mono">{meta.totalMatches ?? meta.matches.length}</span>
</div>
{:else}
<div class="text-xs text-muted-foreground/70 italic">No matches</div>
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
Total matches: <span class="font-mono">{meta?.totalMatches ?? 0}</span>
</div>
@@ -44,15 +44,19 @@
<div class="text-muted-foreground flex items-center gap-2 py-1.5">
<Clock class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
{#if showSpinner}
<span class="text-foreground/80 text-sm font-medium">Current time</span>
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
{:else if dateMeta.errorMessage}
<span class="text-foreground/80 text-sm font-medium">Current time&nbsp;</span>
<span class="text-red-600 text-xs italic dark:text-red-400">-&nbsp;{dateMeta.errorMessage}</span
>
{:else if dateMeta.dateString}
<span class="text-foreground/80 text-sm font-medium">Current time is&nbsp;</span>
<span class="font-mono text-foreground/90 text-sm">{dateMeta.dateString}</span>
{:else}
<span class="text-foreground/80 text-sm font-medium">Current time</span>
@@ -52,18 +52,23 @@
<div class="text-muted-foreground flex items-center gap-2 py-1.5">
<Info class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
{#if showSpinner}
<span class="text-foreground/80 text-sm font-medium">Runtime info</span>
<Loader2 class="text-muted-foreground/70 h-3 w-3 animate-spin" />
{:else if infoMeta.errorMessage}
<span class="text-foreground/80 text-sm font-medium">Runtime info&nbsp;</span>
<span class="text-red-600 text-xs italic dark:text-red-400">-&nbsp;{infoMeta.errorMessage}</span
>
{:else if infoMeta.os || infoMeta.cwd}
<span class="text-foreground/80 text-sm font-medium">Runtime info&nbsp;</span>
{#if infoMeta.os}
<span class="font-mono text-foreground/90 text-sm">{infoMeta.os}</span>
{/if}
{#if infoMeta.cwd}
<span class="font-mono text-foreground/90 text-sm" title={infoMeta.cwd}>{cwdDisplay}</span>
{/if}
@@ -19,12 +19,15 @@
const home = $derived(toolsStore.serverHome);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={grepMeta} {onToggle}>
<ToolCallBlock {isStreaming} meta={grepMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
{#if grepMeta}
<span class="text-muted-foreground">Search for&nbsp;</span>
<span class="font-mono">{grepMeta.pattern}</span>
<span class="text-muted-foreground">&nbsp;in&nbsp;</span>
<span class="font-mono" title={grepMeta.path}>{abbreviateHome(grepMeta.path, home)}</span>
{/if}
{/snippet}
@@ -39,6 +42,7 @@
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
>
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{meta.errorMessage}</span>
</div>
{:else if meta && meta.matches.length > 0}
@@ -46,22 +50,28 @@
{#each meta.matches as match, mi (mi)}
<div class="font-mono text-[11px] leading-relaxed">
<span class="text-muted-foreground/70">{match.file}</span>
{#if meta.showLineNumbers && match.line != null}
<span class="text-muted-foreground/70">:{match.line}</span>
{/if}
<span class="text-muted-foreground/70">:</span>
<span>{match.content}</span>
</div>
{/each}
</div>
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
Total matches: <span class="font-mono">{meta.totalMatches ?? meta.matches.length}</span>
{#if meta.showLineNumbers}
&nbsp;<span class="italic">(with line numbers)</span>
{/if}
</div>
{:else}
<div class="text-xs text-muted-foreground/70 italic">No matches</div>
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
Total matches: <span class="font-mono">{meta?.totalMatches ?? 0}</span>
</div>
@@ -17,10 +17,12 @@
const readFileMeta = $derived(parseReadFileMeta(section));
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={readFileMeta} {onToggle}>
<ToolCallBlock {isStreaming} meta={readFileMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
<span class="text-muted-foreground">Read file </span>
<span class="font-mono">{readFileMeta?.fileName}</span>
{#if readFileMeta?.lineRange}
<span class="text-muted-foreground"
>&nbsp;(lines {readFileMeta.lineRange.start}-{readFileMeta.lineRange.end})</span
@@ -43,9 +43,10 @@
const audioMimeType = $derived(readMediaMeta?.mimeType ?? MimeTypeAudio.MP3_MPEG);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={readMediaMeta} {onToggle}>
<ToolCallBlock {isStreaming} meta={readMediaMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
<span class="text-muted-foreground">Read media </span>
<span class="font-mono">{readMediaMeta?.fileName}</span>
{/snippet}
@@ -57,7 +58,7 @@
</div>
{:else if mediaAttachment.type === AttachmentType.AUDIO}
<div class="mt-2">
<audio controls class="w-full rounded-lg">
<audio class="w-full rounded-lg" controls>
<source
src={createBase64DataUrl(audioMimeType, mediaAttachment.base64Data)}
type={audioMimeType}
@@ -68,10 +69,10 @@
{:else}
<div class="mt-2">
<img
src={mediaAttachment.base64Url}
alt={readMediaMeta?.fileName ?? 'media'}
class="max-h-[60vh] max-w-full rounded-lg object-contain shadow-lg"
loading="lazy"
src={mediaAttachment.base64Url}
/>
</div>
{/if}
@@ -81,6 +82,7 @@
{#if readMediaMeta?.sizeBytes}
<span>Size: {readMediaMeta.sizeBytes} bytes</span>
{/if}
{#if readMediaMeta?.mimeType}
<span>MIME: {readMediaMeta.mimeType}</span>
{/if}
@@ -21,7 +21,7 @@
const title = $derived(getToolUi(section.toolName)?.label ?? section.toolName ?? '');
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={runJsMeta} {title} {onToggle}>
<ToolCallBlock {isStreaming} meta={runJsMeta} {onToggle} {open} {section} {title}>
{#snippet children(meta, ctx)}
{#if ctx.isPending}
<div class="rounded bg-muted/20 p-2 text-xs text-muted-foreground/70 italic">Running...</div>
@@ -30,8 +30,10 @@
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
>
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{meta.errorMessage}</span>
</div>
<div class="mt-3">
<SyntaxHighlightedCode
code={meta.code}
@@ -47,13 +49,17 @@
maxHeight={MAX_HEIGHT_CODE_BLOCK}
streaming={ctx.isCodeStreaming}
/>
<div class="mb-2 mt-3 flex items-center gap-2 text-xs text-muted-foreground/70">
<Terminal class="h-3 w-3" />
<span>Console</span>
{#if meta.timeoutMs != null}
<span class="font-mono">&middot;&nbsp;timeout&nbsp;{meta.timeoutMs}&nbsp;ms</span>
{/if}
</div>
{#if section.toolResult}
<div class="mt-1">
<SyntaxHighlightedCode
@@ -86,55 +86,60 @@
{@const safeUrl = sanitizeExternalUrl(result.url)}
{@const showHoverCard = safeUrl !== null && hasDetails(result)}
{#if safeUrl}
<HoverCard.Root openDelay={150} closeDelay={100}>
<HoverCard.Root closeDelay={100} openDelay={150}>
<HoverCard.Trigger
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
class="hover:bg-muted/80 focus-visible:ring-ring inline-flex max-w-full items-center gap-1.5 rounded-full border bg-muted px-2.5 py-1 text-xs transition-colors outline-none focus-visible:ring-2"
href={safeUrl}
rel="noopener noreferrer"
target="_blank"
>
{#if faviconUrl}
<img
src={faviconUrl}
alt=""
class="h-3 w-3 shrink-0 rounded-sm"
onerror={hideBrokenIcon}
src={faviconUrl}
/>
{:else}
<Globe class="text-muted-foreground/70 h-3 w-3 shrink-0" />
{/if}
<span class="truncate font-medium text-foreground/80">{result.title}</span>
</HoverCard.Trigger>
{#if showHoverCard}
{@const publishDate = formatPublishDate(result.published)}
{@const host = hostFor(safeUrl)}
<HoverCard.Content
side="top"
align="start"
sideOffset={6}
class="bg-popover text-popover-foreground z-50 w-80 max-w-[90vw] rounded-lg border p-0 shadow-lg"
side="top"
sideOffset={6}
>
<div class="flex flex-col gap-2 p-3">
<a
href={safeUrl}
target="_blank"
rel="noopener noreferrer"
class="line-clamp-3 text-sm font-medium leading-snug hover:underline"
>{result.title}</a
href={safeUrl}
rel="noopener noreferrer"
target="_blank">{result.title}</a
>
{#if publishDate || result.author}
<div class="text-muted-foreground flex items-center gap-1.5 text-[11px]">
{#if publishDate}
<span>{publishDate}</span>
{/if}
{#if publishDate && result.author}
<span class="opacity-50">&middot;</span>
{/if}
{#if result.author}
<span class="truncate">{result.author}</span>
{/if}
</div>
{/if}
{#if result.highlights}
<p
class="text-popover-foreground/85 line-clamp-5 text-xs leading-relaxed whitespace-pre-line"
@@ -142,6 +147,7 @@
{result.highlights}
</p>
{/if}
{#if host}
<div class="text-muted-foreground/80 truncate text-[11px]">{host}</div>
{/if}
@@ -152,7 +158,7 @@
{/if}
{/snippet}
<CollapsibleContentBlock {open} class="my-2" {icon} {iconClass} {iconUrl} {title} {onToggle}>
<CollapsibleContentBlock class="my-2" {icon} {iconClass} {iconUrl} {onToggle} {open} {title}>
{#if results.length > 0}
<div class="flex flex-wrap items-center gap-2 pb-1">
{#each results as result (result.url)}
@@ -162,6 +168,7 @@
{:else if showSpinner}
<div class="text-muted-foreground/70 flex items-center gap-2 py-1 text-xs italic">
<Loader2 class="h-3 w-3 animate-spin" />
<span>Searching...</span>
</div>
{:else}
@@ -21,12 +21,14 @@
const home = $derived(toolsStore.serverHome);
</script>
<ToolCallBlock {section} {open} {isStreaming} meta={writeFileMeta} {onToggle}>
<ToolCallBlock {isStreaming} meta={writeFileMeta} {onToggle} {open} {section}>
{#snippet titleSnippet()}
<span class="text-muted-foreground">Write file </span>
<span class="font-mono" title={writeFileMeta?.filePath}
>{abbreviateHome(writeFileMeta?.filePath ?? '', home)}</span
>
{#if writeFileMeta?.errorMessage}
<span class="ml-1 text-xs italic text-muted-foreground/70">(failed)</span>
{/if}
@@ -38,6 +40,7 @@
class="flex items-start gap-2 rounded bg-red-500/10 p-2 text-xs text-red-600 italic dark:text-red-400"
>
<XCircle class="mt-0.5 h-3 w-3 shrink-0" />
<span>{meta.errorMessage}</span>
</div>
{:else if meta}
@@ -47,9 +50,11 @@
maxHeight={MAX_HEIGHT_CODE_BLOCK}
streaming={ctx.isCodeStreaming}
/>
<div class="mt-1.5 text-xs text-muted-foreground/70 italic">
{#if meta.resultMessage}
{meta.resultMessage}{meta.bytesWritten != null ? RESULT_STAT_SEPARATOR : ''}{/if}
{#if meta.bytesWritten != null}
<span class="font-mono">{meta.bytesWritten}</span>
bytes
@@ -1,4 +1,4 @@
<script lang="ts" generics="TMeta">
<script generics="TMeta" lang="ts">
// Generic chrome shell shared by every per-tool block under
// `ChatMessageToolCall/`. Owns:
// - the collapsible wrapper (defaults to CollapsibleContentBlock;
@@ -114,15 +114,15 @@
</script>
<Wrapper
{open}
class="my-2"
icon={toolIcon}
iconClass={toolIconClass}
{iconUrl}
{onToggle}
{open}
{subtitle}
{title}
{titleSnippet}
{subtitle}
{onToggle}
>
{@render children(meta, {
isCodeStreaming,
@@ -69,8 +69,8 @@
<ChatMessageEditForm />
{:else}
<ChatMessageUserBubble
content={message.content}
attachments={message.extra}
content={message.content}
renderMarkdown={true}
/>
@@ -82,8 +82,8 @@
>
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.READING}
promptTokens={storedReadingStats!.promptTokens}
promptMs={storedReadingStats!.promptMs}
promptTokens={storedReadingStats!.promptTokens}
/>
</div>
</div>
@@ -95,10 +95,10 @@
class="inline-flex flex-wrap items-start justify-end gap-2 text-xs text-muted-foreground"
>
<ChatMessageStatistics
mode={ChatMessageStatisticsMode.READING}
isLive
promptTokens={liveStats.tokensProcessed}
mode={ChatMessageStatisticsMode.READING}
promptMs={liveStats.timeMs}
promptTokens={liveStats.tokensProcessed}
/>
</div>
</div>
@@ -54,7 +54,7 @@
{#if attachments && attachments.length > 0}
<div class="mb-2 max-w-[80%]">
<ChatAttachmentsList {attachments} readonly imageHeight="h-40" />
<ChatAttachmentsList {attachments} imageHeight="h-40" readonly />
</div>
{/if}
@@ -37,11 +37,11 @@
<ChatMessageEditForm />
{:else}
<ChatMessageUserBubble
{content}
attachments={extras}
textColorClass="text-muted-foreground"
cardBgClass="dark:bg-primary/8"
{content}
maxHeightStyle="overflow-wrap: anywhere; word-break: break-word;"
textColorClass="text-muted-foreground"
/>
<div class="max-w-[80%]">
@@ -50,9 +50,11 @@
<div
class="pointer-events-auto inset-0 flex items-center gap-1 opacity-0 transition-all duration-150 group-hover:opacity-100"
>
<ActionIcon icon={Edit} tooltip="Edit" onclick={editCtx.handleEdit} />
<ActionIcon icon={Trash2} tooltip="Delete" onclick={onDelete} />
<ActionIcon icon={ArrowUp} tooltip="Send immediately" onclick={onSendImmediately} />
<ActionIcon icon={Edit} onclick={editCtx.handleEdit} tooltip="Edit" />
<ActionIcon icon={Trash2} onclick={onDelete} tooltip="Delete" />
<ActionIcon icon={ArrowUp} onclick={onSendImmediately} tooltip="Send immediately" />
</div>
</div>
</div>
@@ -14,10 +14,12 @@
<div class="my-2 rounded-lg border border-border bg-card p-3">
<div class="mb-3 flex items-center gap-2 text-sm">
<IconComponent class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
<span>
{@render message()}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
{@render actions()}
</div>
@@ -16,13 +16,13 @@
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={() => onDecision(true)}>Continue</Button>
<Button onclick={() => onDecision(true)} size="sm">Continue</Button>
<Button
variant="destructive"
size="sm"
class="text-destructive hover:text-destructive"
onclick={() => onDecision(false)}
size="sm"
variant="destructive"
>
Stop
</Button>
@@ -28,10 +28,10 @@
<DropdownMenu.Root>
<ButtonGroup.Root class="overflow-hidden rounded-md shadow-sm">
<Button
variant="secondary"
size="sm"
class="!rounded-r-none !shadow-none"
onclick={() => onDecision(ToolPermissionDecision.ONCE)}
size="sm"
variant="secondary"
>
Allow once
</Button>
@@ -39,11 +39,11 @@
<ButtonGroup.Separator />
<DropdownMenu.Trigger
aria-label="More allow options"
class={cn(
buttonVariants({ size: 'sm', variant: 'secondary' }),
'inline-flex cursor-pointer items-center !rounded-l-none !shadow-none !px-2'
)}
aria-label="More allow options"
>
<ChevronDown class="h-3.5 w-3.5" />
</DropdownMenu.Trigger>
@@ -54,6 +54,7 @@
Always allow <pre>{toolName}</pre>
tool
</DropdownMenu.Item>
{#if serverLabel}
<DropdownMenu.Item onclick={() => onDecision(ToolPermissionDecision.ALWAYS_SERVER)}>
Always allow all tools from {serverLabel}
@@ -73,7 +74,7 @@
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button variant="destructive" size="sm" onclick={() => onDecision(ToolPermissionDecision.DENY)}>
<Button onclick={() => onDecision(ToolPermissionDecision.DENY)} size="sm" variant="destructive">
Deny
</Button>
{/snippet}
@@ -77,29 +77,30 @@
<div
class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150"
>
<ActionIcon icon={Copy} tooltip="Copy" onclick={messageActions.copy} />
<ActionIcon icon={Copy} onclick={messageActions.copy} tooltip="Copy" />
<ActionIcon icon={Edit} tooltip="Edit" onclick={editCtx.startEdit} />
<ActionIcon icon={Edit} onclick={editCtx.startEdit} tooltip="Edit" />
{#if role === MessageRole.ASSISTANT && onRegenerate}
<ActionIcon icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
<ActionIcon icon={RefreshCw} onclick={() => onRegenerate()} tooltip="Regenerate" />
{/if}
{#if role === MessageRole.ASSISTANT && onContinue}
<ActionIcon icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
<ActionIcon icon={ArrowRight} onclick={onContinue} tooltip="Continue" />
{/if}
{#if messageActions.forkConversation}
<ActionIcon icon={GitBranch} tooltip="Fork conversation" onclick={handleOpenForkDialog} />
<ActionIcon icon={GitBranch} onclick={handleOpenForkDialog} tooltip="Fork conversation" />
{/if}
<ActionIcon icon={Trash2} tooltip="Delete" onclick={messageActions.requestDelete} />
<ActionIcon icon={Trash2} onclick={messageActions.requestDelete} tooltip="Delete" />
</div>
</div>
{#if showRawOutputSwitch}
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">Show raw output</span>
<Switch
checked={rawOutputEnabled}
onCheckedChange={(checked) => onRawOutputToggle?.(checked)}
@@ -109,54 +110,54 @@
</div>
<DialogConfirmation
open={messageActions.showDeleteDialog}
title="Delete Message"
description={messageActions.deletionInfo && messageActions.deletionInfo.totalCount > 1
? `This will delete ${messageActions.deletionInfo.totalCount} messages including: ${messageActions.deletionInfo.userMessages} user message${messageActions.deletionInfo.userMessages > 1 ? 's' : ''} and ${messageActions.deletionInfo.assistantMessages} assistant response${messageActions.deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.`
: 'Are you sure you want to delete this message? This action cannot be undone.'}
cancelText="Cancel"
confirmText={messageActions.deletionInfo && messageActions.deletionInfo.totalCount > 1
? `Delete ${messageActions.deletionInfo.totalCount} Messages`
: 'Delete'}
cancelText="Cancel"
variant="destructive"
description={messageActions.deletionInfo && messageActions.deletionInfo.totalCount > 1
? `This will delete ${messageActions.deletionInfo.totalCount} messages including: ${messageActions.deletionInfo.userMessages} user message${messageActions.deletionInfo.userMessages > 1 ? 's' : ''} and ${messageActions.deletionInfo.assistantMessages} assistant response${messageActions.deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.`
: 'Are you sure you want to delete this message? This action cannot be undone.'}
icon={Trash2}
onConfirm={handleConfirmDelete}
onCancel={() => messageActions.setShowDeleteDialog(false)}
onConfirm={handleConfirmDelete}
open={messageActions.showDeleteDialog}
title="Delete Message"
variant="destructive"
/>
<DialogConfirmation
bind:open={showForkDialog}
title="Fork Conversation"
description="Create a new conversation branching from this message."
confirmText="Fork"
cancelText="Cancel"
confirmText="Fork"
description="Create a new conversation branching from this message."
icon={GitBranch}
onConfirm={handleConfirmFork}
onCancel={() => (showForkDialog = false)}
onConfirm={handleConfirmFork}
title="Fork Conversation"
>
<div class="flex flex-col gap-4 py-2">
<div class="flex flex-col gap-2">
<Label for="fork-name">Title</Label>
<Input
id="fork-name"
bind:value={forkName}
class="text-foreground"
id="fork-name"
placeholder="Enter fork name"
type="text"
bind:value={forkName}
/>
</div>
<div class="flex items-center gap-2">
<Checkbox
id="fork-attachments"
checked={forkIncludeAttachments}
id="fork-attachments"
onCheckedChange={(checked) => {
forkIncludeAttachments = checked === true;
}}
/>
<Label for="fork-attachments" class="cursor-pointer text-sm font-normal">
<Label class="cursor-pointer text-sm font-normal" for="fork-attachments">
Include all attachments
</Label>
</div>
@@ -30,11 +30,11 @@
role="navigation"
>
<ActionIcon
icon={ChevronLeft}
tooltip="Previous version"
disabled={!hasPrevious}
class="h-5 w-5 p-0 {!hasPrevious ? '!cursor-not-allowed opacity-30' : ''}"
disabled={!hasPrevious}
icon={ChevronLeft}
onclick={() => messageActions.navigateToSibling(previousSiblingId!)}
tooltip="Previous version"
/>
<span class="px-1 font-mono text-xs">
@@ -42,11 +42,11 @@
</span>
<ActionIcon
icon={ChevronRight}
tooltip="Next version"
disabled={!hasNext}
class="h-5 w-5 p-0 {!hasNext ? 'opacity-30' : ''}"
disabled={!hasNext}
icon={ChevronRight}
onclick={() => messageActions.navigateToSibling(nextSiblingId!)}
tooltip="Next version"
/>
</div>
{/if}
@@ -181,26 +181,26 @@
{#snippet renderSection(section: AgenticSection, index: number)}
{#if section.type === AgenticSectionType.TEXT}
<div class="agentic-text">
<MarkdownContent content={section.content} attachments={message?.extra} />
<MarkdownContent attachments={message?.extra} content={section.content} />
</div>
{:else if section.type === AgenticSectionType.REASONING || section.type === AgenticSectionType.REASONING_PENDING}
<ChatMessageReasoningBlock
{section}
open={isExpanded(index, section)}
{isStreaming}
{hasReasoningError}
attachments={message?.extra}
{hasReasoningError}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
open={isExpanded(index, section)}
{section}
/>
{:else if section.type === AgenticSectionType.TOOL_CALL || section.type === AgenticSectionType.TOOL_CALL_PENDING || section.type === AgenticSectionType.TOOL_CALL_STREAMING}
<ChatMessageToolCallBlock
{section}
open={isExpanded(index, section)}
{isStreaming}
attachments={message?.extra}
isExecuting={section.toolCallId !== undefined &&
section.toolCallId === currentlyExecutingToolCallId}
attachments={message?.extra}
{isStreaming}
onToggle={() => toggleExpanded(index, section)}
open={isExpanded(index, section)}
{section}
/>
{/if}
{/snippet}
@@ -218,15 +218,15 @@
{#if turnStats && showAgenticTurnStats}
<div class="turn-stats transition-opacity duration-150 mt-1 mb-4">
<ChatMessageStatistics
promptTokens={turnStats.llm.prompt_n}
promptMs={turnStats.llm.prompt_ms}
predictedTokens={turnStats.llm.predicted_n}
predictedMs={turnStats.llm.predicted_ms}
agenticTimings={turnStats.toolCalls.length > 0
? buildTurnAgenticTimings(turnStats)
: undefined}
initialView={ChatMessageStatsView.GENERATION}
hideSummary
initialView={ChatMessageStatsView.GENERATION}
predictedMs={turnStats.llm.predicted_ms}
predictedTokens={turnStats.llm.predicted_n}
promptMs={turnStats.llm.prompt_ms}
promptTokens={turnStats.llm.prompt_n}
/>
</div>
{/if}
@@ -240,9 +240,9 @@
{#if pendingPermission && !permissionDismissed}
<ChatMessageActionCardPermissionRequest
toolName={pendingPermission.toolName}
serverLabel={pendingPermission.serverLabel}
onDecision={handlePermission}
serverLabel={pendingPermission.serverLabel}
toolName={pendingPermission.toolName}
/>
{/if}
@@ -102,35 +102,35 @@
<div class="relative w-full max-w-[80%]">
<ChatForm
value={editCtx.editedContent}
attachments={editCtx.editedExtras}
bind:uploadedFiles={editCtx.editedUploadedFiles}
placeholder="Edit your message..."
showMcpPromptButton
showAddButton={editCtx.messageRole === MessageRole.USER}
showModelSelector={editCtx.messageRole === MessageRole.USER}
onValueChange={editCtx.setContent}
attachments={editCtx.editedExtras}
onAttachmentRemove={handleAttachmentRemove}
onUploadedFileRemove={handleUploadedFileRemove}
onFilesAdd={handleFilesAdd}
onSubmit={handleSubmit}
onUploadedFileRemove={handleUploadedFileRemove}
onValueChange={editCtx.setContent}
placeholder="Edit your message..."
showAddButton={editCtx.messageRole === MessageRole.USER}
showMcpPromptButton
showModelSelector={editCtx.messageRole === MessageRole.USER}
value={editCtx.editedContent}
/>
</div>
<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
{#if isUserMessage && editCtx.showSaveOnlyOption}
<div class="flex items-center gap-2">
<Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
<Switch bind:checked={saveWithoutRegenerate} class="scale-75" id="save-only-switch" />
<label for="save-only-switch" class="cursor-pointer text-xs text-muted-foreground">
<label class="cursor-pointer text-xs text-muted-foreground" for="save-only-switch">
Update without re-sending
</label>
</div>
{:else if isAssistantMessage}
<div class="flex items-center gap-2">
<Switch id="branch-after-edit" bind:checked={branchAfterEdit} class="scale-75" />
<Switch bind:checked={branchAfterEdit} class="scale-75" id="branch-after-edit" />
<label for="branch-after-edit" class="cursor-pointer text-xs text-muted-foreground">
<label class="cursor-pointer text-xs text-muted-foreground" for="branch-after-edit">
Branch conversation after edit
</label>
</div>
@@ -147,12 +147,12 @@
<DialogConfirmation
bind:open={showDiscardDialog}
title="Discard changes?"
description="You have unsaved changes. Are you sure you want to discard them?"
confirmText="Discard"
cancelText="Keep editing"
variant="destructive"
confirmText="Discard"
description="You have unsaved changes. Are you sure you want to discard them?"
icon={AlertTriangle}
onConfirm={editCtx.cancel}
onCancel={() => (showDiscardDialog = false)}
onConfirm={editCtx.cancel}
title="Discard changes?"
variant="destructive"
/>
@@ -124,23 +124,23 @@
</script>
<CollapsibleContentBlock
{open}
class="my-2"
icon={Lightbulb}
iconClass="h-3.5 w-3.5"
{title}
{subtitle}
{shimmerTitle}
{onToggle}
{open}
{shimmerTitle}
{subtitle}
{title}
>
<div
bind:this={scrollEl}
class="reasoning-content"
class:is-streaming={isPending}
class="reasoning-content"
onscroll={handleScrollEvent}
>
{#if currentConfig.renderThinkingAsMarkdown}
<MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
<MarkdownContent {attachments} class="text-muted-foreground" content={section.content} />
{:else}
<div
class="text-[13px] leading-relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
@@ -140,15 +140,15 @@
{#snippet child({ props })}
<button
{...props}
type="button"
class="inline-flex h-5 w-5 items-center justify-center rounded-sm transition-colors {activeView ===
opts.view
? 'bg-background text-foreground shadow-sm'
: opts.disabled
? 'cursor-not-allowed opacity-40'
: 'hover:text-foreground'}"
onclick={() => !opts.disabled && (activeView = opts.view)}
disabled={opts.disabled}
onclick={() => !opts.disabled && (activeView = opts.view)}
type="button"
>
<IconComponent class="h-3 w-3" />
@@ -208,85 +208,85 @@
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={WholeWord}
value="{predictedTokens?.toLocaleString()} tokens"
tooltipLabel="Generated tokens"
value="{predictedTokens?.toLocaleString()} tokens"
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Clock}
value={formattedTime}
tooltipLabel="Generation time"
value={formattedTime}
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Gauge}
value="{tokensPerSecond.toFixed(2)} t/s"
tooltipLabel="Generation speed"
value="{tokensPerSecond.toFixed(2)} t/s"
/>
{:else if activeView === ChatMessageStatsView.TOOLS && hasAgenticStats}
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Wrench}
value="{agenticTimings!.toolCallsCount} calls"
tooltipLabel="Tool calls executed"
value="{agenticTimings!.toolCallsCount} calls"
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Clock}
value={formattedAgenticToolsTime}
tooltipLabel="Tool execution time"
value={formattedAgenticToolsTime}
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Gauge}
value="{agenticToolsPerSecond.toFixed(2)} calls/s"
tooltipLabel="Tool execution rate"
value="{agenticToolsPerSecond.toFixed(2)} calls/s"
/>
{:else if activeView === ChatMessageStatsView.SUMMARY && hasAgenticStats}
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Layers}
value="{agenticTimings!.turns} turns"
tooltipLabel="Agentic turns (LLM calls)"
value="{agenticTimings!.turns} turns"
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={WholeWord}
value="{agenticTimings!.llm.predicted_n.toLocaleString()} tokens"
tooltipLabel="Total tokens generated"
value="{agenticTimings!.llm.predicted_n.toLocaleString()} tokens"
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Clock}
value={formattedAgenticTotalTime}
tooltipLabel="Total time (LLM + tools)"
value={formattedAgenticTotalTime}
/>
{:else if hasPromptStats && (mode === ChatMessageStatisticsMode.READING || isSwitchable)}
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={WholeWord}
value="{promptTokens} tokens"
tooltipLabel="Prompt tokens"
value="{promptTokens} tokens"
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Clock}
value={formattedPromptTime ?? '0s'}
tooltipLabel="Prompt processing time"
value={formattedPromptTime ?? '0s'}
/>
<ChatMessageStatisticsBadge
class="bg-transparent"
icon={Gauge}
value="{promptTokensPerSecond!.toFixed(2)} tokens/s"
tooltipLabel="Prompt processing speed"
value="{promptTokensPerSecond!.toFixed(2)} tokens/s"
/>
{/if}
</div>
@@ -32,6 +32,7 @@
</BadgeInfo>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltipLabel}</p>
</Tooltip.Content>
@@ -227,14 +227,14 @@
<div>
{#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)}
<ChatMessage
class="mx-auto mt-12 w-full max-w-3xl"
{chatActions}
{message}
{toolMessages}
class="mx-auto mt-12 w-full max-w-3xl"
{isLastAssistantMessage}
{isLastUserMessage}
{message}
{nextAssistantMessage}
{siblingInfo}
{toolMessages}
/>
{/each}
@@ -247,10 +247,10 @@
class="mx-auto mt-12 w-full max-w-[48rem]"
content={pendingContent}
extras={agenticStore.getPendingSteeringMessageExtras(convId)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
onDelete={() => agenticStore.clearSteeringMessage(convId)}
onEdit={(newContent, extras) =>
agenticStore.injectSteeringMessage(convId, newContent, extras)}
onDelete={() => agenticStore.clearSteeringMessage(convId)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
/>
{/if}
{:else if conversationsStore.activeConversation && chatStore.getPendingMessageContent(conversationsStore.activeConversation!.id)}
@@ -262,9 +262,9 @@
class="mx-auto mt-12 w-full max-w-[48rem]"
content={pendingContent}
extras={chatStore.getPendingMessageExtras(convId)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
onEdit={(newContent, extras) => chatStore.injectPendingMessage(convId, newContent, extras)}
onDelete={() => chatStore.clearPendingMessage(convId)}
onEdit={(newContent, extras) => chatStore.injectPendingMessage(convId, newContent, extras)}
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
/>
{/if}
{/if}
@@ -294,8 +294,8 @@
<ServerLoadingSplash />
{:else}
<div
class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-[calc(100dvh-1rem-var(--chat-tabs-offset,0px))] px-4 md:py-0 pt-12 pb-48 md:pb-4"
style:--chat-form-bottom-position={chatFormBottomPosition}
class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-[calc(100dvh-1rem-var(--chat-tabs-offset,0px))] px-4 md:py-0 pt-12 pb-48 md:pb-4"
ondragenter={dragAndDrop.dragHandlers.dragenter}
ondragleave={dragAndDrop.dragHandlers.dragleave}
ondragover={dragAndDrop.dragHandlers.dragover}
@@ -313,6 +313,7 @@
{/if}
<div
style:padding-top={!isEmpty ? 'var(--chat-form-padding-top)' : undefined}
class={[
'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
deviceStore.isStandalone
@@ -322,7 +323,6 @@
: 'bottom-2 right-2 left-2',
isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4'
]}
style:padding-top={!isEmpty ? 'var(--chat-form-padding-top)' : undefined}
>
<ChatScreenGreeting {isEmpty} />
@@ -347,6 +347,7 @@
</div>
<ChatScreenForm
bind:uploadedFiles={fileUpload.uploadedFiles}
class="pointer-events-auto conversation-chat-form"
disabled={hasPropsError || chatStore.isEditing()}
{initialMessage}
@@ -356,18 +357,17 @@
onSend={handleSendMessage}
onStop={() => chatStore.stopGeneration()}
onSystemPromptAdd={handleSystemPromptAdd}
bind:uploadedFiles={fileUpload.uploadedFiles}
/>
</div>
</div>
{/if}
<ChatScreenDialogsAndAlerts
{showDeleteDialog}
{handleDeleteConfirm}
{showEmptyFileDialog}
{emptyFileNames}
{activeErrorDialog}
{handleErrorDialogOpenChange}
{emptyFileNames}
{fileUpload}
{handleDeleteConfirm}
{handleErrorDialogOpenChange}
{showDeleteDialog}
{showEmptyFileDialog}
/>
@@ -8,12 +8,12 @@
<div class="pointer-events-auto flex justify-center relative h-0">
<ActionIcon
icon={ArrowDown}
{onclick}
ariaLabel="Scroll to bottom"
tooltip="Scroll to bottom"
size="lg"
iconSize={ICON_CLASS_DEFAULT}
class="h-9 w-9 rounded-full bg-muted/60 border border-border/20 shadow-sm text-accent-foreground absolute bottom-4"
icon={ArrowDown}
iconSize={ICON_CLASS_DEFAULT}
{onclick}
size="lg"
tooltip="Scroll to bottom"
/>
</div>
@@ -26,14 +26,14 @@
<DialogConfirmation
bind:open={showDeleteDialog}
title="Delete Conversation"
description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
confirmText="Delete"
cancelText="Cancel"
variant="destructive"
confirmText="Delete"
description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
icon={Trash2}
onConfirm={handleDeleteConfirm}
onCancel={() => (showDeleteDialog = false)}
onConfirm={handleDeleteConfirm}
title="Delete Conversation"
variant="destructive"
/>
<DialogEmptyFileAlert
@@ -47,8 +47,8 @@
/>
<DialogChatError
message={activeErrorDialog?.message ?? ''}
contextInfo={activeErrorDialog?.contextInfo}
message={activeErrorDialog?.message ?? ''}
onOpenChange={handleErrorDialogOpenChange}
open={Boolean(activeErrorDialog)}
type={activeErrorDialog?.type ?? ErrorDialogType.SERVER}

Some files were not shown because too many files have changed in this diff Show More