From 933f46f3cbced79798b163dff70ccb1a819e63d4 Mon Sep 17 00:00:00 2001 From: Georgi Gerganov Date: Fri, 7 Aug 2026 17:10:01 +0300 Subject: [PATCH 001/107] ggml : bump version to 0.19.0 (ggml/1581) --- ggml/CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ggml/CMakeLists.txt b/ggml/CMakeLists.txt index 6c7337edd398..1b1de6b7d451 100644 --- a/ggml/CMakeLists.txt +++ b/ggml/CMakeLists.txt @@ -4,8 +4,8 @@ project("ggml" C CXX ASM) ### GGML Version set(GGML_VERSION_MAJOR 0) -set(GGML_VERSION_MINOR 18) -set(GGML_VERSION_PATCH 1) +set(GGML_VERSION_MINOR 19) +set(GGML_VERSION_PATCH 0) set(GGML_VERSION_BASE "${GGML_VERSION_MAJOR}.${GGML_VERSION_MINOR}.${GGML_VERSION_PATCH}") list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/") From 4cf5cab65d5257be31e7623eb552b1861e969c75 Mon Sep 17 00:00:00 2001 From: Georgi Gerganov Date: Fri, 7 Aug 2026 17:10:39 +0300 Subject: [PATCH 002/107] sync : ggml --- scripts/sync-ggml.last | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sync-ggml.last b/scripts/sync-ggml.last index 35e94d9fb614..7af9ecb0f60a 100644 --- a/scripts/sync-ggml.last +++ b/scripts/sync-ggml.last @@ -1 +1 @@ -90951f99af1fbebef3fbdd58ff5b8715b0bb9c43 +30bf8685ed4eb0a47f2b06229543327749904150 From 4cb22cd537a9b12b717bd725b3fecb83c94894eb Mon Sep 17 00:00:00 2001 From: Xuan-Son Nguyen Date: Fri, 7 Aug 2026 18:05:15 +0200 Subject: [PATCH 003/107] mtmd: fix longest_edge ignoring min/max pixels (#26638) * mtmd: fix longest_edge ignoring min/max pixels * nits --- tools/mtmd/clip-model.h | 11 +++++ tools/mtmd/clip.cpp | 3 ++ tools/mtmd/mtmd-image.cpp | 96 ++++++++++++++++++++------------------- 3 files changed, 64 insertions(+), 46 deletions(-) diff --git a/tools/mtmd/clip-model.h b/tools/mtmd/clip-model.h index 101f49cd1849..7db01b576bd7 100644 --- a/tools/mtmd/clip-model.h +++ b/tools/mtmd/clip-model.h @@ -170,6 +170,17 @@ struct clip_hparams { warmup_image_size = static_cast(std::sqrt(image_max_pixels)); } + // used by longest_edge preprocessor (no model-specific value for min/max tokens) + void set_limit_image_tokens() { + const int patch_area = patch_size * patch_size * n_merge * n_merge; + if (custom_image_min_tokens > 0) { + image_min_pixels = custom_image_min_tokens * patch_area; + } + if (custom_image_max_tokens > 0) { + image_max_pixels = custom_image_max_tokens * patch_area; + } + } + void set_warmup_n_tokens(int n_tokens) { int n_tok_per_side = static_cast(std::sqrt(n_tokens)); GGML_ASSERT(n_tok_per_side * n_tok_per_side == n_tokens && "n_tokens must be n*n"); diff --git a/tools/mtmd/clip.cpp b/tools/mtmd/clip.cpp index b1360fd7d309..3b6105629870 100644 --- a/tools/mtmd/clip.cpp +++ b/tools/mtmd/clip.cpp @@ -1434,6 +1434,7 @@ struct clip_model_loader { // use default llava-uhd preprocessing params get_u32(KEY_PROJ_SCALE_FACTOR, hparams.n_merge, false); get_u32(KEY_PREPROC_IMAGE_SIZE, hparams.image_longest_edge, false); + hparams.set_limit_image_tokens(); } break; case PROJECTOR_TYPE_LFM2: { @@ -1471,6 +1472,7 @@ struct clip_model_loader { get_u32(KEY_SPATIAL_MERGE_SIZE, hparams.n_merge, false); hparams.image_longest_edge = hparams.image_size; get_u32(KEY_PREPROC_IMAGE_SIZE, hparams.image_longest_edge, false); + hparams.set_limit_image_tokens(); hparams.set_warmup_n_tokens(256); // avoid OOM on warmup } break; case PROJECTOR_TYPE_DOTS_OCR: @@ -1595,6 +1597,7 @@ struct clip_model_loader { if (hparams.image_longest_edge == 0) { hparams.image_longest_edge = 3024; } + // note: the step3vl preprocessor slices based on a fixed window grid, so it does not support custom min/max image tokens hparams.warmup_image_size = hparams.image_size; } break; case PROJECTOR_TYPE_YOUTUVL: diff --git a/tools/mtmd/mtmd-image.cpp b/tools/mtmd/mtmd-image.cpp index 10cfe52f56f7..073d83d45344 100644 --- a/tools/mtmd/mtmd-image.cpp +++ b/tools/mtmd/mtmd-image.cpp @@ -139,50 +139,46 @@ struct img_tool { } } - // calculate the size of the **resized** image, while preserving the aspect ratio - // the calculated size will be aligned to the nearest multiple of align_size - // if H or W size is larger than longest_edge, it will be resized to longest_edge - static clip_image_size calc_size_preserved_ratio(const clip_image_size & inp_size, const int align_size, const int longest_edge) { - GGML_ASSERT(align_size > 0); - if (inp_size.width <= 0 || inp_size.height <= 0 || longest_edge <= 0) { - return {0, 0}; - } - - float scale = std::min(static_cast(longest_edge) / inp_size.width, - static_cast(longest_edge) / inp_size.height); - - float target_width_f = static_cast(inp_size.width) * scale; - float target_height_f = static_cast(inp_size.height) * scale; - - auto ceil_by_factor = [f = align_size](float x) { return static_cast(std::ceil(x / static_cast(f))) * f; }; - int aligned_width = ceil_by_factor(target_width_f); - int aligned_height = ceil_by_factor(target_height_f); - - return {aligned_width, aligned_height}; - } + struct calc_size_opt { + int align_size = 1; + int min_pixels = 0; // 0 = disabled + int max_pixels = 0; // 0 = disabled + // applied before min/max_pixels, so min_pixels can push an edge back above longest_edge + int longest_edge = 0; // 0 = disabled + }; - // calculate the size of the **resized** image, while preserving the aspect ratio - // the calculated size will have min_pixels <= W*H <= max_pixels - // this is referred as "smart_resize" in transformers code - static clip_image_size calc_size_preserved_ratio(const clip_image_size & inp_size, const int align_size, const int min_pixels, const int max_pixels) { - GGML_ASSERT(align_size > 0); + // calculate the size of the **resized** image, while preserving the aspect ratio and + // aligning to the nearest multiple of align_size ("smart_resize" in transformers code) + static clip_image_size calc_size_preserved_ratio(const clip_image_size & inp_size, const calc_size_opt & opts) { + GGML_ASSERT(opts.align_size > 0); const int width = inp_size.width; const int height = inp_size.height; + if (width <= 0 || height <= 0) { + return {0, 0}; + } - auto round_by_factor = [f = align_size](float x) { return static_cast(std::round(x / static_cast(f))) * f; }; - auto ceil_by_factor = [f = align_size](float x) { return static_cast(std::ceil(x / static_cast(f))) * f; }; - auto floor_by_factor = [f = align_size](float x) { return static_cast(std::floor(x / static_cast(f))) * f; }; + auto round_by_factor = [f = opts.align_size](float x) { return static_cast(std::round(x / static_cast(f))) * f; }; + auto ceil_by_factor = [f = opts.align_size](float x) { return static_cast(std::ceil(x / static_cast(f))) * f; }; + auto floor_by_factor = [f = opts.align_size](float x) { return static_cast(std::floor(x / static_cast(f))) * f; }; - // always align up first - int h_bar = std::max(align_size, round_by_factor(height)); - int w_bar = std::max(align_size, round_by_factor(width)); + int w_bar, h_bar; + if (opts.longest_edge > 0) { + const float scale = std::min(static_cast(opts.longest_edge) / width, + static_cast(opts.longest_edge) / height); + w_bar = ceil_by_factor(width * scale); + h_bar = ceil_by_factor(height * scale); + } else { + // always align up first + w_bar = std::max(opts.align_size, round_by_factor(width)); + h_bar = std::max(opts.align_size, round_by_factor(height)); + } - if (h_bar * w_bar > max_pixels) { - const auto beta = std::sqrt(static_cast(height * width) / max_pixels); - h_bar = std::max(align_size, floor_by_factor(height / beta)); - w_bar = std::max(align_size, floor_by_factor(width / beta)); - } else if (h_bar * w_bar < min_pixels) { - const auto beta = std::sqrt(static_cast(min_pixels) / (height * width)); + if (opts.max_pixels > 0 && h_bar * w_bar > opts.max_pixels) { + const auto beta = std::sqrt(static_cast(height) * width / opts.max_pixels); + h_bar = std::max(opts.align_size, floor_by_factor(height / beta)); + w_bar = std::max(opts.align_size, floor_by_factor(width / beta)); + } else if (opts.min_pixels > 0 && h_bar * w_bar < opts.min_pixels) { + const auto beta = std::sqrt(static_cast(opts.min_pixels) / (static_cast(height) * width)); h_bar = ceil_by_factor(height * beta); w_bar = ceil_by_factor(width * beta); } @@ -937,9 +933,12 @@ mtmd_image_preproc_out mtmd_image_preprocessor_dyn_size::preprocess(const clip_i const int cur_merge = hparams.n_merge; const clip_image_size target_size = img_tool::calc_size_preserved_ratio( original_size, - hparams.patch_size * cur_merge, - hparams.image_min_pixels, - hparams.image_max_pixels); + { + /* align_size */ hparams.patch_size * cur_merge, + /* min_pixels */ hparams.image_min_pixels, + /* max_pixels */ hparams.image_max_pixels, + /* longest_edge */ 0, + }); img_tool::resize(img, resized_image, target_size, hparams.image_resize_algo, hparams.image_resize_pad, @@ -961,8 +960,12 @@ mtmd_image_preproc_out mtmd_image_preprocessor_longest_edge::preprocess(const cl const int cur_merge = hparams.n_merge == 0 ? 1 : hparams.n_merge; const clip_image_size target_size = img_tool::calc_size_preserved_ratio( original_size, - hparams.patch_size * cur_merge, - hparams.image_longest_edge); + { + /* align_size */ hparams.patch_size * cur_merge, + /* min_pixels */ std::max(0, hparams.image_min_pixels), + /* max_pixels */ std::max(0, hparams.image_max_pixels), + /* longest_edge */ hparams.image_longest_edge, + }); img_tool::resize(img, resized_image, target_size, hparams.image_resize_algo, hparams.image_resize_pad, @@ -1000,8 +1003,8 @@ mtmd_image_preprocessor_llava_uhd::slice_instructions mtmd_image_preprocessor_lf mtmd_image_preprocessor_llava_uhd::slice_instructions inst; const int align_size = hparams.patch_size * hparams.n_merge; inst.overview_size = img_tool::calc_size_preserved_ratio( - original_size, align_size, - hparams.image_min_pixels, hparams.image_max_pixels); + original_size, + { align_size, hparams.image_min_pixels, hparams.image_max_pixels, 0 }); // tile if either dimension exceeds tile_size with tolerance const bool needs_tiling = original_size.width > tile_size * max_pixels_tolerance || original_size.height > tile_size * max_pixels_tolerance; @@ -1109,7 +1112,8 @@ mtmd_image_preproc_out mtmd_image_preprocessor_idefics3::preprocess(const clip_i // CITE: https://github.com/huggingface/transformers/blob/main/src/transformers/models/idefics3/image_processing_idefics3.py#L737 const clip_image_size original_size = img.get_size(); const clip_image_size refined_size = img_tool::calc_size_preserved_ratio( - original_size, hparams.image_size, hparams.image_longest_edge); + original_size, + { hparams.image_size, std::max(0, hparams.image_min_pixels), std::max(0, hparams.image_max_pixels), hparams.image_longest_edge }); // LOG_INF("%s: original size: %d x %d, refined size: %d x %d\n", // __func__, original_size.width, original_size.height, // refined_size.width, refined_size.height); From 23634783c541ed49e0adfd0c64c452d77d1051ed Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 7 Aug 2026 18:45:54 +0200 Subject: [PATCH 004/107] ui: Filesystem `@mentions` for Chat Form (#26715) * base : @-mention picker foundation - glob search, picker nav, highlight * feat : @-mention file/folder picker and mention badges in message bubbles * fix: Imports * feat : wire the @-mention picker into the chat form * fix: Bound the glob-search result cache key and prune stale entries --- .../app/chat/ChatForm/ChatForm.svelte | 160 +++++++---- .../ChatFormMentionPicker.svelte | 258 ++++++++++++++++++ .../ChatFormPicker/ChatFormPickerList.svelte | 73 +++-- .../ChatFormPickerListItem.svelte | 17 +- .../ChatFormPickerPopover.svelte | 1 + .../ChatFormPickerMcpResources.svelte | 237 ---------------- .../ChatFormPickers/ChatFormPickers.svelte | 50 ++-- .../app/chat/ChatForm/ChatFormTextarea.svelte | 10 + tools/ui/src/lib/components/app/chat/index.ts | 29 +- .../MarkdownContent/MarkdownContent.svelte | 2 + .../plugins/rehype/file-badge.ts | 100 +++++++ .../app/forms/HighlightedMatch.svelte | 25 ++ .../ui/src/lib/components/app/forms/index.ts | 8 + tools/ui/src/lib/constants/chat-form.ts | 1 - tools/ui/src/lib/constants/index.ts | 1 + tools/ui/src/lib/constants/mention-badge.ts | 39 +++ tools/ui/src/lib/constants/settings-keys.ts | 2 + .../ui/src/lib/constants/settings-registry.ts | 27 +- .../ui/src/lib/constants/working-directory.ts | 6 + tools/ui/src/lib/enums/chat.enums.ts | 5 + tools/ui/src/lib/enums/index.ts | 3 +- tools/ui/src/lib/enums/keyboard.enums.ts | 1 + .../lib/hooks/use-debounced-search.svelte.ts | 67 +++++ .../lib/hooks/use-picker-navigation.svelte.ts | 108 ++++++++ .../lib/hooks/use-scroll-active-row.svelte.ts | 47 ++++ tools/ui/src/lib/types/chat.d.ts | 12 +- tools/ui/src/lib/types/index.ts | 3 +- tools/ui/src/lib/types/settings.d.ts | 6 + tools/ui/src/lib/utils/glob-search.ts | 151 ++++++++++ tools/ui/src/lib/utils/index.ts | 34 +++ tools/ui/src/lib/utils/mention-badge.ts | 101 +++++++ tools/ui/src/lib/utils/mention-token.ts | 73 +++++ tools/ui/src/lib/utils/path-display.ts | 34 +-- tools/ui/src/lib/utils/working-directory.ts | 50 ++-- .../components/PickerListScrollHarness.svelte | 43 +++ .../client/picker-list-scroll.svelte.test.ts | 29 ++ .../tests/unit/glob-search-children.test.ts | 119 ++++++++ tools/ui/tests/unit/mention-badge.test.ts | 202 ++++++++++++++ tools/ui/tests/unit/mention-token.test.ts | 64 +++++ tools/ui/tests/unit/working-directory.test.ts | 40 +++ 40 files changed, 1839 insertions(+), 399 deletions(-) create mode 100644 tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte delete mode 100644 tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte create mode 100644 tools/ui/src/lib/components/app/content/MarkdownContent/plugins/rehype/file-badge.ts create mode 100644 tools/ui/src/lib/components/app/forms/HighlightedMatch.svelte create mode 100644 tools/ui/src/lib/constants/mention-badge.ts create mode 100644 tools/ui/src/lib/hooks/use-debounced-search.svelte.ts create mode 100644 tools/ui/src/lib/hooks/use-picker-navigation.svelte.ts create mode 100644 tools/ui/src/lib/hooks/use-scroll-active-row.svelte.ts create mode 100644 tools/ui/src/lib/utils/glob-search.ts create mode 100644 tools/ui/src/lib/utils/mention-badge.ts create mode 100644 tools/ui/src/lib/utils/mention-token.ts create mode 100644 tools/ui/tests/client/components/PickerListScrollHarness.svelte create mode 100644 tools/ui/tests/client/picker-list-scroll.svelte.test.ts create mode 100644 tools/ui/tests/unit/glob-search-children.test.ts create mode 100644 tools/ui/tests/unit/mention-badge.test.ts create mode 100644 tools/ui/tests/unit/mention-token.test.ts diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte index 105e414fe78f..22406e072257 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -15,8 +15,7 @@ SETTING_CONFIG_DEFAULT, INITIAL_FILE_SIZE, PROMPT_CONTENT_SEPARATOR, - PROMPT_TRIGGER_PREFIX, - RESOURCE_TRIGGER_PREFIX + PROMPT_TRIGGER_PREFIX } from '$lib/constants'; import { ContentPartType, @@ -39,8 +38,23 @@ activeConversation, pendingCwd } from '$lib/stores/conversations.svelte'; - import type { GetPromptResult, MCPPromptInfo, MCPResourceInfo, PromptMessage } from '$lib/types'; - import { isIMEComposing, parseClipboardContent, uuid } from '$lib/utils'; + import type { + FileMentionEntry, + GetPromptResult, + MCPPromptInfo, + MCPResourceInfo, + PromptMessage + } from '$lib/types'; + import { + buildMentionInsertion, + findMentionToken, + isIMEComposing, + mentionLinkEndingAt, + parseClipboardContent, + takeMentionDismissSnapshot, + type MentionDismissSnapshot, + uuid + } from '$lib/utils'; import { AudioRecorder, convertToWav, @@ -108,11 +122,18 @@ let isRecording = $state(false); let recordingSupported = $state(false); + // Invisible anchor at the form's top edge so the mention popover floats above the box. + let mentionAnchor: HTMLDivElement | null = $state(null); + // Picker State let isPromptPickerOpen = $state(false); let promptSearchQuery = $state(''); - let isInlineResourcePickerOpen = $state(false); - let resourceSearchQuery = $state(''); + let isMentionPickerOpen = $state(false); + let mentionQuery = $state(''); + + // Last dismissed `@`-mention token; while intact the picker does not + // reopen, so an escaped `@` stays literal until edited. + let mentionDismissedSnapshot: MentionDismissSnapshot | null = null; let cwd = $derived(activeConversation()?.cwd ?? pendingCwd()); @@ -219,26 +240,44 @@ function handleInput() { const perChatOverrides = conversationsStore.getAllMcpServerOverrides(); const hasServers = mcpStore.hasEnabledServers(perChatOverrides); + const cursor = textareaRef?.getCaretOffset() ?? value.length; + const mentionToken = findMentionToken(value, cursor); + + // A `@` mention takes precedence; typing one switches from any other open picker. + if (mentionToken && mentionToken.query.length > 0) { + isPromptPickerOpen = false; + promptSearchQuery = ''; + + const isDismissedSticky = + mentionDismissedSnapshot !== null && + mentionDismissedSnapshot.start === mentionToken.start && + mentionDismissedSnapshot.query === mentionToken.query; + + if (!isDismissedSticky) { + mentionDismissedSnapshot = null; + isMentionPickerOpen = true; + mentionQuery = mentionToken.query; + return; + } + + isMentionPickerOpen = false; + mentionQuery = ''; + return; + } + + isMentionPickerOpen = false; + mentionQuery = ''; + // Token gone or changed: reset the snapshot so a fresh `@` reopens. + if (mentionDismissedSnapshot !== null && !mentionToken) { + mentionDismissedSnapshot = null; + } if (value.startsWith(PROMPT_TRIGGER_PREFIX) && hasServers) { isPromptPickerOpen = true; promptSearchQuery = value.slice(1); - isInlineResourcePickerOpen = false; - resourceSearchQuery = ''; - } else if ( - value.startsWith(RESOURCE_TRIGGER_PREFIX) && - hasServers && - mcpStore.hasResourcesCapability(perChatOverrides) - ) { - isInlineResourcePickerOpen = true; - resourceSearchQuery = value.slice(1); - isPromptPickerOpen = false; - promptSearchQuery = ''; } else { isPromptPickerOpen = false; promptSearchQuery = ''; - isInlineResourcePickerOpen = false; - resourceSearchQuery = ''; } } @@ -247,15 +286,30 @@ return; } + // Backspace at a mention link's end deletes the whole token at once. + if (event.key === KeyboardKey.BACKSPACE && !event.ctrlKey && !event.metaKey && !event.altKey) { + const el = textareaRef?.getElement(); + if (el instanceof HTMLTextAreaElement && el.selectionStart === el.selectionEnd) { + const link = mentionLinkEndingAt(value, el.selectionStart); + if (link) { + event.preventDefault(); + value = value.slice(0, link.start) + value.slice(link.end); + onValueChange?.(value); + queueMicrotask(() => textareaRef?.setCaretOffset(link.start)); + return; + } + } + } + if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) { isPromptPickerOpen = false; promptSearchQuery = ''; return; } - if (event.key === KeyboardKey.ESCAPE && isInlineResourcePickerOpen) { - isInlineResourcePickerOpen = false; - resourceSearchQuery = ''; + if (event.key === KeyboardKey.ESCAPE && isMentionPickerOpen) { + isMentionPickerOpen = false; + mentionQuery = ''; return; } @@ -432,33 +486,33 @@ textareaRef?.focus(); } - function handleInlineResourcePickerClose() { - isInlineResourcePickerOpen = false; - resourceSearchQuery = ''; - textareaRef?.focus(); - } - - function handleInlineResourceSelect() { - if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) { - value = ''; - onValueChange?.(''); + function handleMentionPickerClose() { + if (isMentionPickerOpen) { + const cursor = textareaRef?.getCaretOffset() ?? value.length; + mentionDismissedSnapshot = takeMentionDismissSnapshot(value, cursor); } - - isInlineResourcePickerOpen = false; - resourceSearchQuery = ''; - textareaRef?.focus(); + isMentionPickerOpen = false; + mentionQuery = ''; + refocusInput(); } - function handleBrowseResources() { - isInlineResourcePickerOpen = false; - resourceSearchQuery = ''; + // Splice the `[name](file:///)` link in place of the `@` + // token, restoring the caret after the bindable value settles. + function handleMentionSelect(entry: FileMentionEntry) { + const cursor = textareaRef?.getCaretOffset() ?? value.length; + const token = findMentionToken(value, cursor); + if (!token) return; - if (value.startsWith(RESOURCE_TRIGGER_PREFIX)) { - value = ''; - onValueChange?.(''); - } + const built = buildMentionInsertion(entry, value, token); + if (!built) return; + + value = built.newValue; + onValueChange?.(built.newValue); - isResourceDialogOpen = true; + queueMicrotask(() => { + textareaRef?.focus(); + textareaRef?.setCaretOffset(built.caretOffset); + }); } async function handleMicClick() { @@ -505,17 +559,25 @@ bind:this={pickersRef} {isPromptPickerOpen} {promptSearchQuery} - {isInlineResourcePickerOpen} - {resourceSearchQuery} + {isMentionPickerOpen} + {mentionQuery} + {mentionAnchor} + scopePath={cwd} onPromptPickerClose={handlePromptPickerClose} - onInlineResourcePickerClose={handleInlineResourcePickerClose} - onInlineResourceSelect={handleInlineResourceSelect} + onMentionPickerClose={handleMentionPickerClose} + onMentionOpened={() => textareaRef?.focus()} + onMentionSelect={handleMentionSelect} onPromptLoadStart={handlePromptLoadStart} onPromptLoadComplete={handlePromptLoadComplete} onPromptLoadError={handlePromptLoadError} - onInlineResourceBrowse={handleBrowseResources} /> + +
+ import { File, Folder } from '@lucide/svelte'; + import { abbreviateHome, runGlobSearchWithChildren, type GlobEntryResult } from '$lib/utils'; + import { toolsStore } from '$lib/stores/tools.svelte'; + import { BuiltInTool, FileMentionEntryType, GlobSearchType } from '$lib/enums'; + import { isMobile } from '$lib/stores/viewport.svelte'; + import { config } from '$lib/stores/settings.svelte'; + import * as Popover from '$lib/components/ui/popover'; + import * as Tooltip from '$lib/components/ui/tooltip'; + import HighlightedMatch from '$lib/components/app/forms/HighlightedMatch.svelte'; + import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat'; + import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte'; + import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte'; + import type { FileMentionEntry } from '$lib/types'; + import { + FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH, + HOME_TILDE, + SEARCH_DEBOUNCE_MS + } from '$lib/constants'; + + /** + * Floating file/folder mention picker. The chat input is the search + * surface: `query` (typed after `@`) drives a `file_glob_search` tool + * call scoped to `scopePath`. The parent owns the "dismissed token, + * don't re-open until it changes" snapshot. + */ + interface Props { + class?: string; + isOpen: boolean; + query: string; + customAnchor?: HTMLElement | null; + scopePath?: string | null; + onClose: () => void; + onSelect: (entry: FileMentionEntry) => void; + /** Fired when `isOpen` becomes true, so the host can keep focus on the chat input. */ + onOpened?: () => void; + } + + let { + class: className = '', + isOpen, + query, + customAnchor = null, + scopePath = null, + onClose, + onSelect, + onOpened + }: Props = $props(); + + const nav = usePickerNavigation({ + isOpen: () => isOpen, + count: () => displayedItems.length, + onClose: () => onClose(), + onSelect: (index) => handleSelect(displayedItems[index]) + }); + + // When the server does not expose file_glob_search (started without + // --tools) or the user disabled it, the picker still opens but explains + // why instead of firing searches that would only fail. + const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.FILE_GLOB_SEARCH)); + const fileSearchEnabled = $derived( + fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey) + ); + + let searchResults = $state([]); + let searchError = $state(null); + + // Coerce the depth setting to a positive integer; an invalid value + // would otherwise reach the server as max_depth 0 = unlimited. + const searchDepth = $derived.by(() => { + const n = Number(config().mentionSearchMaxDepth); + return Number.isInteger(n) && n > 0 ? n : FILE_GLOB_SEARCH_PICKERS_DEFAULT_SEARCH_DEPTH; + }); + + const home = $derived(toolsStore.serverHome); + + // A smaller window than the WD picker suffices: entries are ranked client-side. + const MENTION_SEARCH_LIMIT = 50; + + const search = useDebouncedSearch({ + debounceMs: SEARCH_DEBOUNCE_MS, + canRun: () => isOpen && fileSearchEnabled, + getQuery: () => trimmedQuery, + run: async (query, signal, isCurrent) => { + try { + // A trailing path separator targets a directory, so also list its + // children. Accept both `/` and `\`. + const res = await runGlobSearchWithChildren( + query, + scopePath ?? home ?? HOME_TILDE, + searchDepth, + MENTION_SEARCH_LIMIT, + signal, + { type: GlobSearchType.ALL, descendOnTrailingSeparator: true } + ); + if (!isCurrent()) return; + if (res.error) { + searchResults = []; + searchError = res.error; + return; + } + const toEntry = (e: GlobEntryResult): FileMentionEntry => ({ + path: e.path, + name: e.name, + type: e.type === 'dir' ? FileMentionEntryType.DIRECTORY : FileMentionEntryType.FILE + }); + searchResults = res.entries.map(toEntry); + searchError = null; + } catch (err) { + if (!isCurrent() || signal.aborted) return; + searchResults = []; + searchError = err instanceof Error ? err.message : String(err); + } + } + }); + + const trimmedQuery = $derived((query ?? '').trim()); + const displayedItems = $derived(searchResults); + + const emptyMessage = $derived.by(() => { + if (fileSearchKey === null) { + return 'File search is unavailable on this server (started without --tools)'; + } + if (!fileSearchEnabled) { + return 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions'; + } + return searchError ? `Search failed - ${searchError}` : 'No matching files or folders'; + }); + + const showTooltip = $derived(!isMobile.current); + + $effect(() => { + if (typeof window === 'undefined') return; + void toolsStore.resolveServerHome(); + }); + + $effect(() => { + if (isOpen) { + nav.reset(0); + } + }); + + $effect(() => { + if (isOpen) onOpened?.(); + }); + + $effect(() => { + const q = (query ?? '').trim(); + if (!isOpen || !q || !fileSearchEnabled) { + search.cancel(); + searchResults = []; + searchError = null; + return; + } + search.setLoading(true); + search.run(q); + }); + + function handleSelect(entry: FileMentionEntry) { + onSelect(entry); + onClose(); + } + + export function handleKeydown(event: KeyboardEvent): boolean { + return nav.handleKeydown(event); + } + + + { + if (!open) onClose(); + }} +> + + + + 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 + ]} + > + entry.type + ':' + entry.path} + scrollTrigger={nav.scrollTrigger} + > + {#snippet item(entry, index, isSelected)} + handleSelect(entry)} + onmouseenter={() => nav.setHover(index)} + > + {@const Icon = entry.type === FileMentionEntryType.DIRECTORY ? Folder : File} + +
+
+ {#if showTooltip} + + + {#snippet child({ props })} + {entry.name} + {/snippet} + + +

{entry.path}

+
+
+ {:else} + {entry.name} + {/if} + + {entry.type} + +
+ + + +
+
+ {/snippet} +
+
+
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte index 6647928b2bf5..04b85b24b235 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte @@ -2,6 +2,7 @@ import type { Snippet } from 'svelte'; import { SearchInput } from '$lib/components/app'; import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte'; + import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte'; import { CHAT_FORM_POPOVER_MAX_HEIGHT } from '$lib/constants'; interface Props { @@ -11,11 +12,19 @@ searchQuery: string; showSearchInput: boolean; searchPlaceholder?: string; + // Omit to distinguish "haven't searched yet" from "search returned nothing". emptyMessage?: string; + autofocus?: boolean; + inputRef?: HTMLInputElement | null; + onSearchClose?: () => void; itemKey: (item: T, index: number) => string; item: Snippet<[T, number, boolean]>; skeleton?: Snippet; + skeletonCount?: number; footer?: Snippet; + // Counter bumped by the picker on keyboard nav; scrolls the selected + // row into view without scrolling on hover or result replacement. + scrollTrigger?: number; } let { @@ -25,49 +34,69 @@ searchQuery = $bindable(), showSearchInput, searchPlaceholder = 'Search...', - emptyMessage = 'No items available', + emptyMessage, + autofocus = false, + inputRef = $bindable(null), + onSearchClose, itemKey, item, skeleton, - footer + skeletonCount = 6, + footer, + scrollTrigger }: Props = $props(); let listContainer = $state(null); - $effect(() => { - if (listContainer && selectedIndex >= 0 && selectedIndex < items.length) { - const selectedElement = listContainer.querySelector( - `[data-picker-index="${selectedIndex}"]` - ) as HTMLElement; + let listPaddingTop = $derived( + showSearchInput ? (isLoading || items.length > 0 ? 'pt-13' : 'pt-10') : '' + ); - if (selectedElement) { - selectedElement.scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'nearest' - }); - } - } + // selectedIndex/items.length are untracked so hover and result replacement + // never re-fire the scroll; keyboard nav is the only path that bumps the trigger. + useScrollActiveRow({ + getTrigger: () => scrollTrigger, + getContainer: () => listContainer, + getIndex: () => selectedIndex, + getCount: () => items.length, + dataIndex: 'picker' }); {#if showSearchInput}
- +
{/if} -
+
{#if isLoading} {#if skeleton} {@render skeleton()} + {:else} +
+ {#each { length: skeletonCount } as _, rowIndex (rowIndex)} +
+
+
+
+
+
+
+ {/each} +
+ {/if} + {:else if items && items.length === 0} + {#if emptyMessage} +
{emptyMessage}
{/if} - {:else if items.length === 0} -
{emptyMessage}
{:else} {#each items as itemData, index (itemKey(itemData, index))} {@render item(itemData, index, index === selectedIndex)} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte index 4d82c6b5849c..0fb0ea0c0082 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte @@ -3,21 +3,34 @@ interface Props { isSelected?: boolean; + disabled?: boolean; onclick: () => void; + onmouseenter?: () => void; dataIndex?: number; children: Snippet; + class?: string; } - let { isSelected = false, onclick, dataIndex, children }: Props = $props(); + let { + class: className = '', + isSelected = false, + disabled = false, + onclick, + onmouseenter, + dataIndex, + children + }: Props = $props(); diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte index c43a002e6953..24ea8619f575 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerPopover.svelte @@ -42,6 +42,7 @@ align="start" sideOffset={12} class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl {className}" + preventScroll={false} onkeydown={onKeydown} onOpenAutoFocus={(event) => event.preventDefault()} > diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte deleted file mode 100644 index ed97e1fc7e5e..000000000000 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte +++ /dev/null @@ -1,237 +0,0 @@ - - - - resource.serverName + ':' + resource.uri} - > - {#snippet item(resource, index, isSelected)} - {@const server = serverSettingsMap.get(resource.serverName)} - {@const serverLabel = server ? mcpStore.getServerLabel(server) : resource.serverName} - - handleResourceClick(resource)} - > - - {#snippet titleExtra()} - {#if isResourceAttached(resource.uri)} - - attached - - {/if} - {/snippet} - - {#snippet subtitle()} -

- {resource.uri} -

- {/snippet} -
-
- {/snippet} - - {#snippet skeleton()} - - {/snippet} - - {#snippet footer()} - {#if onBrowse && resources.length > 3} - - {/if} - {/snippet} -
-
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte index 7c5dc85b2a09..3fc29f177163 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte @@ -1,16 +1,19 @@
diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts index 57108f306521..19d26cb09e52 100644 --- a/tools/ui/src/lib/components/app/chat/index.ts +++ b/tools/ui/src/lib/components/app/chat/index.ts @@ -351,14 +351,14 @@ export { default as ChatFormPickerPopover } from './ChatForm/ChatFormPickers/Cha * Generic scrollable list for picker popovers. Provides search input, * scroll-into-view for keyboard navigation, loading skeletons, empty state, * and optional footer. Uses Svelte 5 snippets for item/skeleton/footer rendering. - * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources. + * Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker. */ export { default as ChatFormPickerList } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerList.svelte'; /** * Generic button wrapper for picker list items. Provides consistent styling, * hover/selected states, and data-picker-index attribute for scroll-into-view. - * Shared by ChatFormPickerMcpPrompts and ChatFormPickerMcpResources. + * Shared by ChatFormPickerMcpPrompts and ChatFormMentionPicker. */ export { default as ChatFormPickerListItem } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItem.svelte'; @@ -376,30 +376,19 @@ export { default as ChatFormPickerItemHeader } from './ChatForm/ChatFormPickers/ export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPickers/ChatFormPicker/ChatFormPickerListItemSkeleton.svelte'; /** - * **ChatFormPickerMcpResources** - MCP resource selection interface - * - * Floating picker for browsing and attaching MCP Server Resources. - * Triggered by typing `@` in the chat input. - * Loads resources from connected MCP servers and allows users to attach them to the chat context. - * - * **Features:** - * - Search/filter resources by name, title, description, or URI across all connected servers - * - Keyboard navigation (↑/↓ to navigate, Enter to select, Esc to close) - * - Shows attached state for already-attached resources - * - Loading states with skeleton placeholders - * - Server information header per resource for visual identification - * - * **Exported API:** - * - `handleKeydown(event): boolean` - Process keyboard events, returns true if handled + * `@`-triggered file/folder mention picker. Resolves `@` in the chat + * input to a filesystem match via the server's `file_glob_search` built-in + * tool, scoped to the conversation cwd (or server home when unset). + * Selection splices a `[name](file:///)` link into the input. */ -export { default as ChatFormPickerMcpResources } from './ChatForm/ChatFormPickers/ChatFormPickerMcpResources.svelte'; +export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte'; /** * **ChatFormPickers** - Chat input picker container * - * Container component that hosts both MCP prompt and MCP resource pickers. + * Container component that hosts the MCP prompt and file mention pickers. * Manages shared state, keyboard navigation, and coordination between the two - * picker interfaces. Used within ChatForm for `@`-triggered pickers. + * picker interfaces. Used within ChatForm. */ export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte'; diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte index fc7e314122f7..be6041b63c2d 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte @@ -15,6 +15,7 @@ import { SvelteMap } from 'svelte/reactivity'; import { rehypeRestoreTableHtml } from './plugins/rehype/table-html-restorer'; import { rehypeEnhanceLinks } from './plugins/rehype/enhance-links'; + import { rehypeFileBadge } from './plugins/rehype/file-badge'; import { rehypeEnhanceCodeBlocks } from './plugins/rehype/enhance-code-blocks'; import { rehypeEnhanceMermaidBlocks } from './plugins/rehype/enhance-mermaid-blocks'; import { rehypeMermaidPre } from './plugins/rehype/mermaid-pre'; @@ -174,6 +175,7 @@ }) // Add syntax highlighting .use(rehypeRestoreTableHtml) // Restore limited HTML (e.g.,
,