allozaur/feat/chat slash commands (#26716)

* base : slash-command/misc foundation - model icon and focus-selector constants

* feat : slash-command picker and command parsing helpers

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

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

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

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

* feat: Clamp and style numeric settings inputs from registry bounds
This commit is contained in:
Aleksander Grygier
2026-08-07 20:20:01 +02:00
committed by GitHub
parent f8e30266d2
commit 6de1b63473
41 changed files with 1425 additions and 506 deletions
@@ -14,8 +14,7 @@
INPUT_CLASSES,
SETTING_CONFIG_DEFAULT,
INITIAL_FILE_SIZE,
PROMPT_CONTENT_SEPARATOR,
PROMPT_TRIGGER_PREFIX
PROMPT_CONTENT_SEPARATOR
} from '$lib/constants';
import {
ContentPartType,
@@ -47,14 +46,14 @@
} from '$lib/types';
import {
buildMentionInsertion,
findCommandToken,
findMentionToken,
isIMEComposing,
mentionLinkEndingAt,
parseClipboardContent,
takeMentionDismissSnapshot,
type MentionDismissSnapshot,
uuid
} from '$lib/utils';
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
import {
AudioRecorder,
convertToWav,
@@ -110,37 +109,52 @@
onValueChange
}: Props = $props();
// Component References
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined =
$state(undefined);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
let inputRef: ChatFormTextarea | undefined = $state(undefined);
// Audio Recording State
let isRecording = $state(false);
let recordingSupported = $state(false);
// Invisible anchor at the form's top edge so the mention popover floats above the box.
// Invisible anchor at the form's top edge so the mention/WD popovers
// float above the box.
let mentionAnchor: HTMLDivElement | null = $state(null);
// Picker State
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
let isMentionPickerOpen = $state(false);
let mentionQuery = $state('');
// Last dismissed `@`-mention token; while intact the picker does not
// reopen, so an escaped `@<query>` stays literal until edited.
let mentionDismissedSnapshot: MentionDismissSnapshot | null = null;
let cwd = $derived(activeConversation()?.cwd ?? pendingCwd());
async function handleWorkingDirectoryChange(value: string | null) {
await conversationsStore.setCwd(value);
const pickers = useChatFormPickers({
getValue: () => value,
setValue: (v) => {
value = v;
onValueChange?.(v);
},
getCaretOffset: () => inputRef?.getCaretOffset(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
focusInput: refocusInput,
getShowModelSelector: () => showModelSelector,
hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()),
hasBuiltinTools: () => toolsStore.builtinTools.length > 0,
getCwd: () => cwd,
getServerHome: () => toolsStore.serverHome ?? null,
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
getPickersRef: () => pickersRef
});
async function handleWorkingDirectoryChange(newDir: string | null) {
// Committing a directory consumes the `/cwd` token; the chip's
// clear-X path has no token to consume.
const token = findCommandToken(value);
if (token && token.name === 'cwd') {
value = '';
onValueChange?.('');
}
await conversationsStore.setCwd(newDir);
if (conversationsStore.activeConversation) {
await chatStore.recordCwdChange(value?.trim() || null);
await chatStore.recordCwdChange(newDir?.trim() || null);
}
}
@@ -192,18 +206,12 @@
audioRecorder = new AudioRecorder();
});
// Defer so the closing popover's focus scope tears down first - bits-ui
// yanks a synchronous focus() back into the still-mounted popover.
function refocusInput() {
queueMicrotask(() => textareaRef?.focus());
}
export function focus() {
textareaRef?.focus();
inputRef?.focus();
}
export function resetTextareaHeight() {
textareaRef?.resetHeight();
inputRef?.resetHeight();
}
export function openModelSelector() {
@@ -237,82 +245,28 @@
}
}
function handleInput() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
const cursor = textareaRef?.getCaretOffset() ?? value.length;
const mentionToken = findMentionToken(value, cursor);
// A `@` mention takes precedence; typing one switches from any other open picker.
if (mentionToken && mentionToken.query.length > 0) {
isPromptPickerOpen = false;
promptSearchQuery = '';
const isDismissedSticky =
mentionDismissedSnapshot !== null &&
mentionDismissedSnapshot.start === mentionToken.start &&
mentionDismissedSnapshot.query === mentionToken.query;
if (!isDismissedSticky) {
mentionDismissedSnapshot = null;
isMentionPickerOpen = true;
mentionQuery = mentionToken.query;
return;
}
isMentionPickerOpen = false;
mentionQuery = '';
return;
}
isMentionPickerOpen = false;
mentionQuery = '';
// Token gone or changed: reset the snapshot so a fresh `@` reopens.
if (mentionDismissedSnapshot !== null && !mentionToken) {
mentionDismissedSnapshot = null;
}
if (value.startsWith(PROMPT_TRIGGER_PREFIX) && hasServers) {
isPromptPickerOpen = true;
promptSearchQuery = value.slice(1);
} else {
isPromptPickerOpen = false;
promptSearchQuery = '';
}
}
function handleKeydown(event: KeyboardEvent) {
if (pickersRef?.handleKeydown(event)) {
// Pickers consume navigation/escape keys first; when consumed, skip
// the enter-to-submit logic below.
if (pickers.handleKeydown(event)) {
return;
}
// Backspace at a mention link's end deletes the whole token at once.
if (event.key === KeyboardKey.BACKSPACE && !event.ctrlKey && !event.metaKey && !event.altKey) {
const el = textareaRef?.getElement();
const el = inputRef?.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));
queueMicrotask(() => inputRef?.setCaretOffset(link.start));
return;
}
}
}
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
isPromptPickerOpen = false;
promptSearchQuery = '';
return;
}
if (event.key === KeyboardKey.ESCAPE && isMentionPickerOpen) {
isMentionPickerOpen = false;
mentionQuery = '';
return;
}
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
const isModifier = event.ctrlKey || event.metaKey;
const sendOnEnter = currentConfig.sendOnEnter !== false;
@@ -386,7 +340,7 @@
}
setTimeout(() => {
textareaRef?.focus();
inputRef?.focus();
}, 10);
return;
@@ -413,13 +367,7 @@
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) {
// Only clear the value if the prompt was triggered by typing '/'
if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
value = '';
onValueChange?.('');
}
isPromptPickerOpen = false;
promptSearchQuery = '';
pickers.closePromptPicker();
const promptName = promptInfo.title || promptInfo.name;
const placeholder: ChatUploadedFile = {
@@ -438,7 +386,7 @@
uploadedFiles = [...uploadedFiles, placeholder];
onUploadedFilesChange?.(uploadedFiles);
textareaRef?.focus();
inputRef?.focus();
}
function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) {
@@ -480,26 +428,16 @@
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptPickerClose() {
isPromptPickerOpen = false;
promptSearchQuery = '';
textareaRef?.focus();
// Deferred so the closing popover's focus scope tears down first -
// bits-ui yanks a synchronous focus() back into the still-mounted popover.
function refocusInput() {
queueMicrotask(() => inputRef?.focus());
}
function handleMentionPickerClose() {
if (isMentionPickerOpen) {
const cursor = textareaRef?.getCaretOffset() ?? value.length;
mentionDismissedSnapshot = takeMentionDismissSnapshot(value, cursor);
}
isMentionPickerOpen = false;
mentionQuery = '';
refocusInput();
}
// Splice the `[name](file:///<abs path>)` link in place of the `@<query>`
// token, restoring the caret after the bindable value settles.
// Splice the mention link in place of the `@<query>` token. Uses the
// live cursor, not a stale snapshot - the token may have been edited.
function handleMentionSelect(entry: FileMentionEntry) {
const cursor = textareaRef?.getCaretOffset() ?? value.length;
const cursor = inputRef?.getCaretOffset() ?? value.length;
const token = findMentionToken(value, cursor);
if (!token) return;
@@ -509,9 +447,10 @@
value = built.newValue;
onValueChange?.(built.newValue);
// bind:value applies on the next microtask; restore the caret after.
queueMicrotask(() => {
textareaRef?.focus();
textareaRef?.setCaretOffset(built.caretOffset);
inputRef?.focus();
inputRef?.setCaretOffset(built.caretOffset);
});
}
@@ -557,15 +496,20 @@
>
<ChatFormPickers
bind:this={pickersRef}
{isPromptPickerOpen}
{promptSearchQuery}
{isMentionPickerOpen}
{mentionQuery}
isCommandPickerOpen={pickers.isCommandPickerOpen}
commandQuery={pickers.commandQuery}
commands={pickers.availableCommands}
onCommandPickerClose={pickers.handleCommandPickerClose}
onCommandSelect={pickers.handleCommandSelect}
isPromptPickerOpen={pickers.isPromptPickerOpen}
promptSearchQuery={pickers.promptSearchQuery}
isMentionPickerOpen={pickers.isMentionPickerOpen}
mentionQuery={pickers.mentionQuery}
{mentionAnchor}
scopePath={cwd}
onPromptPickerClose={handlePromptPickerClose}
onMentionPickerClose={handleMentionPickerClose}
onMentionOpened={() => textareaRef?.focus()}
scopePath={pickers.mentionScopePath}
onPromptPickerClose={pickers.handlePromptPickerClose}
onMentionPickerClose={pickers.handleMentionPickerClose}
onMentionOpened={() => inputRef?.focus()}
onMentionSelect={handleMentionSelect}
onPromptLoadStart={handlePromptLoadStart}
onPromptLoadComplete={handlePromptLoadComplete}
@@ -596,17 +540,17 @@
<div
class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
onpaste={handlePaste}
>
<ChatFormTextarea
class="px-5 py-1.5 md:pt-0"
bind:this={textareaRef}
bind:this={inputRef}
bind:value
onKeydown={handleKeydown}
onInput={() => {
handleInput();
pickers.handleInput();
onValueChange?.(value);
}}
onPaste={handlePaste}
{disabled}
{placeholder}
/>
@@ -636,7 +580,7 @@
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
/>
</div>
@@ -647,8 +591,12 @@
{#if toolsStore.builtinTools.length > 0}
<ChatFormWorkingDirectory
directory={cwd}
isOpen={pickers.isWorkingDirectoryPickerOpen}
bind:query={pickers.workingDirectoryQuery}
customAnchor={mentionAnchor}
onChange={handleWorkingDirectoryChange}
onClose={refocusInput}
onClose={pickers.handleWorkingDirectoryClose}
onOpen={pickers.handleWorkingDirectoryOpen}
{disabled}
/>
{/if}
@@ -0,0 +1,135 @@
<script lang="ts">
import { FolderOpen, Sparkles } from '@lucide/svelte';
import { MODEL_SELECTOR_ICON } from '$lib/constants';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { ChatFormCommandAction } from '$lib/enums';
import type { ChatFormCommand } from '$lib/types';
import {
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerPopover
} from '$lib/components/app/chat';
/**
* Slash-command picker; `query` (typed after `/`) filters the commands.
* The parent owns the "dismissed token, don't act until it changes"
* snapshot, so this picker just renders and reports selection.
*/
interface Props {
class?: string;
isOpen: boolean;
query: string;
commands: ChatFormCommand[];
onClose: () => void;
onSelect: (command: ChatFormCommand) => void;
}
let { class: className = '', isOpen, query, commands, onClose, onSelect }: Props = $props();
const commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
[ChatFormCommandAction.PROMPT]: Sparkles,
[ChatFormCommandAction.CWD]: FolderOpen,
[ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON
};
const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
const filteredCommands = $derived(
trimmedQuery
? commands.filter(
(c) =>
c.name.toLowerCase().includes(trimmedQuery) ||
c.description.toLowerCase().includes(trimmedQuery) ||
(c.keywords ?? []).some((k) => k.toLowerCase().includes(trimmedQuery))
)
: commands
);
function firstEnabledIndex(): number {
return filteredCommands.findIndex((c) => !c.disabled);
}
function stepEnabled(from: number, dir: number): number {
const n = filteredCommands.length;
if (n === 0) return -1;
for (let i = 1; i <= n; i++) {
const idx = (from + dir * i + n) % n;
if (!filteredCommands[idx].disabled) return idx;
}
return -1;
}
const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => filteredCommands.length,
step: (from, dir) => (from < 0 ? firstEnabledIndex() : stepEnabled(from, dir)),
onClose: () => onClose(),
onSelect: (index) => handleSelect(filteredCommands[index])
});
$effect(() => {
if (isOpen) {
nav.reset(firstEnabledIndex());
}
});
$effect(() => {
if (nav.hoveredIndex < 0 || nav.hoveredIndex >= filteredCommands.length) {
nav.reset(firstEnabledIndex());
return;
}
if (filteredCommands[nav.hoveredIndex].disabled) {
nav.reset(firstEnabledIndex());
}
});
function handleSelect(command: ChatFormCommand) {
if (command.disabled) return;
onSelect(command);
onClose();
}
export function handleKeydown(event: KeyboardEvent): boolean {
return nav.handleKeydown(event);
}
</script>
<ChatFormPickerPopover
bind:isOpen
class={className}
srLabel="Open command picker"
{onClose}
onKeydown={handleKeydown}
>
<ChatFormPickerList
items={filteredCommands}
isLoading={false}
selectedIndex={nav.hoveredIndex}
showSearchInput={false}
searchQuery={query ?? ''}
emptyMessage="No matching command"
itemKey={(command) => command.name}
scrollTrigger={nav.scrollTrigger}
>
{#snippet item(command, index, isSelected)}
{@const Icon = commandIcon[command.action]}
<ChatFormPickerListItem
dataIndex={index}
{isSelected}
disabled={command.disabled}
onclick={() => handleSelect(command)}
onmouseenter={() => {
if (!command.disabled) nav.setHover(index);
}}
>
<Icon class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div class="flex min-w-0 flex-1 flex-col">
<span class="font-mono text-sm font-medium">/{command.name}</span>
<span class="min-w-0 flex-1 truncate text-left text-xs text-muted-foreground">
{command.description}
</span>
</div>
</ChatFormPickerListItem>
{/snippet}
</ChatFormPickerList>
</ChatFormPickerPopover>
@@ -45,6 +45,9 @@
let promptArgs = $state<Record<string, string>>({});
let selectedIndex = $state(0);
let internalSearchQuery = $state('');
// Bumped on ArrowUp/ArrowDown only, so the list scrolls on keyboard
// nav but not on hover or result changes.
let scrollTrigger = $state(0);
let promptError = $state<string | null>(null);
let selectedIndexBeforeArgumentForm = $state<number | null>(null);
@@ -295,6 +298,7 @@
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
scrollTrigger++;
}
return true;
@@ -304,6 +308,7 @@
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
scrollTrigger++;
}
return true;
@@ -400,6 +405,7 @@
searchPlaceholder="Search prompts..."
emptyMessage="No MCP prompts available"
itemKey={(prompt) => prompt.serverName + ':' + prompt.name}
{scrollTrigger}
>
{#snippet item(prompt, index, isSelected)}
{@const server = serverSettingsMap.get(prompt.serverName)}
@@ -1,15 +1,26 @@
<script lang="ts">
import ChatFormCommandPicker from './ChatFormCommandPicker.svelte';
import ChatFormMentionPicker from './ChatFormMentionPicker.svelte';
import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
import type { FileMentionEntry, GetPromptResult, MCPPromptInfo } from '$lib/types';
import type {
ChatFormCommand,
FileMentionEntry,
GetPromptResult,
MCPPromptInfo
} from '$lib/types';
interface Props {
isCommandPickerOpen?: boolean;
commandQuery?: string;
commands?: ChatFormCommand[];
isPromptPickerOpen?: boolean;
promptSearchQuery?: string;
isMentionPickerOpen?: boolean;
mentionQuery?: string;
mentionAnchor?: HTMLElement | null;
scopePath?: string | null;
onCommandPickerClose?: () => void;
onCommandSelect?: (command: ChatFormCommand) => void;
onPromptPickerClose?: () => void;
onMentionPickerClose?: () => void;
onMentionOpened?: () => void;
@@ -24,6 +35,11 @@
}
let {
isCommandPickerOpen,
commandQuery,
commands = [],
onCommandPickerClose,
onCommandSelect,
isPromptPickerOpen,
promptSearchQuery,
isMentionPickerOpen,
@@ -39,14 +55,16 @@
onPromptLoadError
}: Props = $props();
let commandPickerRef: ChatFormCommandPicker | undefined = $state(undefined);
let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
let mentionPickerRef: ChatFormMentionPicker | undefined = $state(undefined);
/**
* Delegates keyboard events to the active picker child.
* Returns true if the event was handled.
*/
/** Delegate keyboard events to the active picker child; true if handled. */
export function handleKeydown(event: KeyboardEvent): boolean {
if (isCommandPickerOpen && commandPickerRef?.handleKeydown(event)) {
return true;
}
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
return true;
}
@@ -59,6 +77,15 @@
}
</script>
<ChatFormCommandPicker
bind:this={commandPickerRef}
isOpen={isCommandPickerOpen ?? false}
query={commandQuery ?? ''}
{commands}
onClose={onCommandPickerClose ?? (() => {})}
onSelect={onCommandSelect ?? (() => {})}
/>
<ChatFormPickerMcpPrompts
bind:this={promptPickerRef}
isOpen={isPromptPickerOpen}
@@ -28,11 +28,10 @@
onMount(() => {
if (textareaElement) {
autoResizeTextarea(textareaElement);
textareaElement.focus();
textareaElement.focus({ preventScroll: true });
}
});
// Expose the textarea element for external access
export function getElement() {
return textareaElement;
}
@@ -49,7 +48,7 @@
}
}
// Plain-text caret offsets for the mention-splice flow.
// Plain-text caret offsets for the picker/paste/mention-splice flows.
export function getCaretOffset(): number {
if (!textareaElement) return 0;
return textareaElement.selectionStart ?? textareaElement.value.length;
@@ -1,7 +1,5 @@
<script lang="ts">
import { FolderOpen } from '@lucide/svelte';
import { untrack } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { ToolsService } from '$lib/services/tools.service';
import { toolsStore } from '$lib/stores/tools.svelte';
import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
@@ -10,23 +8,22 @@
buildCaseInsensitiveGlob,
joinPath,
lastPathSegment,
rankEntries,
splitPathQuery,
runGlobSearchWithChildren,
type GlobEntry
} from '$lib/utils';
import { debounce } from '$lib/utils/debounce';
import * as Popover from '$lib/components/ui/popover';
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte';
import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.svelte';
import {
DEFAULT_MOBILE_BREAKPOINT,
GLOB_WILDCARD,
HOME_TILDE,
MAX_RESULTS_SHOWN,
NATIVE_LIMIT,
NATIVE_MAX_DEPTH,
PATH_NAV_MAX_DEPTH,
SEARCH_DEBOUNCE_MS,
SEARCH_LIMIT,
SEARCH_MAX_DEPTH
@@ -39,228 +36,147 @@
class?: string;
disabled?: boolean;
directory?: string | null;
/** Controlled open state; the host owns it so the chip click and the
* `/cwd` slash command open the picker through the same path. */
isOpen: boolean;
/** Two-way bound query, kept in sync with the text after `/cwd `. */
query: string;
/** Anchor at the form's top edge so the popover floats above the box. */
customAnchor?: HTMLElement | null;
onChange?: (directory: string | null) => void;
/**
* Lets the host refocus the chat input so typing can resume without
* an extra click after the popover closes.
*/
/** Lets the host refocus the chat input after the popover closes. */
onClose?: () => void;
/** Fired when the chip is clicked so the host can open the picker. */
onOpen?: () => void;
}
let {
class: className = '',
disabled = false,
directory = $bindable(null),
directory = null,
isOpen,
query = $bindable(''),
customAnchor = null,
onChange,
onClose
onClose,
onOpen
}: Props = $props();
// File System Access API is opt-in: when available (Chrome / Edge / Opera) the popover
// exposes a "Browse" button that opens the native folder picker. When unavailable the
// popover still works via the text input - no alerts, no upload semantics.
// File System Access API is opt-in (Chrome / Edge / Opera): the popover
// exposes a "Browse" button only when available.
const pickerSupported =
typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function';
// Popover open state; the element handles outside-click and Escape.
let isOpen = $state(false);
let inputValue = $state('');
let searchInputRef: HTMLInputElement | null = $state(null);
let queryResults = $state<string[]>([]);
let isSearching = $state(false);
let searchError = $state<string | null>(null);
let hoveredIndex = $state(-1);
// Bumped only by ArrowUp/ArrowDown handlers; the list scrolls the
// highlighted row into view only via this trigger, never on hover.
let scrollTrigger = $state(0);
let listContainer = $state<HTMLDivElement | null>(null);
// Absolute home directory on the server, resolved once per session by
// the tools store. Anchors both the search scope and the chip's `~`
// abbreviation.
const nav = usePickerNavigation({
isOpen: () => isOpen,
count: () => queryResults.length,
onClose: closePicker,
onSelect: (index) => commit(queryResults[index])
});
let homeBase = $derived(toolsStore.serverHome);
// AbortController + sequence counter to discard stale responses when the user
// keeps typing; a newer call aborts the previous one. The sequence counter
// also covers the gap between abort and the catch handler.
let searchController: AbortController | null = null;
let searchSeq = 0;
// Cache of the last file_glob_search result per (parent, include, max_depth),
// so repeated queries in the same directory don't re-walk the tree. Entering
// a directory hits it every time: the children listed for an exactly typed
// segment are what the next keystroke, the trailing slash, asks for again.
const SEARCH_CACHE_TTL_MS = 2000;
const searchCache = new SvelteMap<string, { results: GlobEntry[]; base: string; at: number }>();
const runSearch = debounce((query: string) => {
void doSearch(query);
}, SEARCH_DEBOUNCE_MS);
// Resolve home eagerly on mount so the chip can abbreviate before the
// user opens the picker. resolveServerHome() is cached, so repeat calls
// (e.g. from handleOpenChange) are no-ops.
// Resolve home eagerly so the chip can abbreviate before the picker opens.
$effect(() => {
if (typeof window === 'undefined') return;
void toolsStore.resolveServerHome();
});
// Auto-focus the search input when the popover opens.
// HTML `autofocus` is unreliable on dynamically shown elements, so we
// use a microtask (0ms setTimeout) after the effect flushes.
// HTML `autofocus` is unreliable on dynamically shown elements.
$effect(() => {
if (!isOpen) return;
setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
});
let lastScrollTrigger: number | null = null;
// hoveredIndex/queryResults are untracked so hover and result replacement
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger
$effect(() => {
if (scrollTrigger === lastScrollTrigger) return;
lastScrollTrigger = scrollTrigger;
untrack(() => {
if (!listContainer) return;
if (hoveredIndex < 0 || hoveredIndex >= queryResults.length) return;
const selectedElement = listContainer.querySelector(
`[data-result-index="${hoveredIndex}"]`
) as HTMLElement | null;
selectedElement?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
if (!isOpen) return;
const q = query.trim();
nav.reset(-1);
if (q) {
search.run(q);
} else {
search.cancel();
queryResults = [];
searchError = null;
nav.reset(-1);
searchScope = homeBase ?? HOME_TILDE;
}
});
function cancelSearch() {
searchController?.abort();
searchSeq++;
isSearching = false;
}
useScrollActiveRow({
getTrigger: () => nav.scrollTrigger,
getContainer: () => listContainer,
getIndex: () => nav.hoveredIndex,
getCount: () => queryResults.length,
dataIndex: 'result'
});
// Effective directory the current search runs against (shown in the
// footer); updated by doSearch, including when an exactly-typed
// directory is "entered".
let searchScope = $state(HOME_TILDE);
// Runs a directory listing through the cache, so a repeated query in the
// same directory does not re-walk the tree on the server.
async function searchDirs(
path: string,
include: string,
maxDepth: number,
signal: AbortSignal
): Promise<{ base: string; entries: GlobEntry[]; error?: string }> {
const key = `${path}\u0000${include}\u0000${maxDepth}`;
const cached = searchCache.get(key);
if (cached && Date.now() - cached.at < SEARCH_CACHE_TTL_MS) {
return { base: cached.base, entries: cached.results };
}
const res = await ToolsService.executeToolRaw(
BuiltInTool.FILE_GLOB_SEARCH,
{ path, type: GlobSearchType.DIR, include, max_depth: maxDepth, limit: SEARCH_LIMIT },
signal
);
if (typeof res.error === 'string') return { base: '', entries: [], error: res.error };
const base = typeof res.base === 'string' ? res.base : '';
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
const now = Date.now();
for (const [k, v] of searchCache) {
if (now - v.at >= SEARCH_CACHE_TTL_MS) searchCache.delete(k);
}
searchCache.set(key, { results: entries, base, at: now });
return { base, entries };
}
async function doSearch(query: string) {
const trimmed = query.trim();
if (!trimmed) {
queryResults = [];
searchError = null;
isSearching = false;
hoveredIndex = -1;
searchScope = homeBase ?? HOME_TILDE;
return;
}
cancelSearch();
const controller = new AbortController();
searchController = controller;
const mySeq = ++searchSeq;
const pathQuery = splitPathQuery(trimmed);
isSearching = true;
try {
// A generous limit is requested because ranking happens
// client-side; only the top 20 are shown.
const searchPath = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
const include = pathQuery
? pathQuery.last
? buildCaseInsensitiveGlob(pathQuery.last)
: GLOB_WILDCARD
: buildCaseInsensitiveGlob(trimmed);
const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : SEARCH_MAX_DEPTH;
const res = await searchDirs(searchPath, include, maxDepth, controller.signal);
if (mySeq !== searchSeq) return;
if (res.error) {
// An exactly-typed directory is "entered": the shared search lists its
// children too, so path navigation does not require a trailing slash.
const search = useDebouncedSearch({
debounceMs: SEARCH_DEBOUNCE_MS,
canRun: () => isOpen,
getQuery: () => query.trim(),
run: async (q, signal, isCurrent) => {
const trimmed = q.trim();
if (!trimmed) {
queryResults = [];
hoveredIndex = -1;
searchError = res.error;
searchError = null;
nav.reset(-1);
searchScope = homeBase ?? HOME_TILDE;
return;
}
const { base, entries } = res;
const ranked = rankEntries(entries, pathQuery?.last ?? trimmed);
let results = ranked.map((e) => joinPath(base, e.path));
searchScope = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
// An exactly-typed directory is "entered": list its children too,
// so path navigation doesn't require a trailing slash.
const last = pathQuery?.last;
const exact = last
? ranked.find((e) => lastPathSegment(e.path).toLowerCase() === last.toLowerCase())
: undefined;
if (exact) {
const exactDir = joinPath(base, exact.path);
const childRes = await searchDirs(
exactDir,
GLOB_WILDCARD,
PATH_NAV_MAX_DEPTH,
controller.signal
try {
// Generous limit: ranking is client-side, only the top
// MAX_RESULTS_SHOWN are shown.
const res = await runGlobSearchWithChildren(
trimmed,
homeBase ?? HOME_TILDE,
SEARCH_MAX_DEPTH,
SEARCH_LIMIT,
signal,
{ type: GlobSearchType.DIR }
);
if (mySeq !== searchSeq) return;
if (!childRes.error) {
const children = childRes.entries
.map((e) => joinPath(childRes.base, e.path))
.sort((a, b) => a.localeCompare(b));
results = [...results, ...children];
searchScope = exactDir;
if (!isCurrent()) return;
if (res.error) {
queryResults = [];
nav.reset(-1);
searchError = res.error;
return;
}
searchScope = res.exactDir ?? res.args.path;
queryResults = res.entries.map((e) => e.path).slice(0, MAX_RESULTS_SHOWN);
if (queryResults.length > 0) {
nav.reset(0);
nav.bumpScroll(); // scroll the list back to the top (first item is hovered)
} else {
nav.reset(-1);
}
searchError = null;
} catch (err) {
if (!isCurrent() || signal.aborted) return;
queryResults = [];
nav.reset(-1);
searchError = err instanceof Error ? err.message : String(err);
}
queryResults = results.slice(0, MAX_RESULTS_SHOWN);
hoveredIndex = queryResults.length > 0 ? 0 : -1;
// new results: scroll the list back to the top (first item is hovered)
if (hoveredIndex === 0) scrollTrigger++;
searchError = null;
} catch (err) {
if (mySeq !== searchSeq) return;
queryResults = [];
hoveredIndex = -1;
if (controller.signal.aborted) return;
searchError = err instanceof Error ? err.message : String(err);
} finally {
if (mySeq === searchSeq) isSearching = false;
}
}
// Single funnel for every local close so the host refocus fires
// regardless of which commit/dismiss path ended the interaction.
});
// Single funnel for every local close so the host refocus always fires.
function closePicker() {
isOpen = false;
onClose?.();
}
function commit(path: string) {
directory = path;
onChange?.(path);
closePicker();
}
@@ -268,15 +184,12 @@
function setDirectory(value: string) {
const trimmed = value.trim();
if (!trimmed) return;
directory = trimmed;
onChange?.(trimmed);
}
// Resolve a folder name picked via the browser-native picker (which exposes
// only the leaf name) to a server-side absolute path. Returns null when the
// server cannot locate a matching directory, so the caller can fail visibly
// instead of committing a bare leaf name that would resolve against the
// server process working directory.
// Resolve a browser-picked folder name (which exposes only the leaf name)
// to a server-side absolute path; null when the server cannot locate it,
// so the caller fails visibly instead of committing a bare leaf name.
async function resolveNativeName(name: string): Promise<string | null> {
try {
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
@@ -318,7 +231,7 @@
}
function handleSubmit() {
const value = inputValue.trim();
const value = query.trim();
if (!value) {
closePicker();
return;
@@ -330,47 +243,33 @@
function handleKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
// Commit the highlighted result, falling back to the raw input
// only when the query returned no matches.
if (hoveredIndex >= 0 && queryResults[hoveredIndex]) {
commit(queryResults[hoveredIndex]);
if (nav.hoveredIndex >= 0 && queryResults[nav.hoveredIndex]) {
commit(queryResults[nav.hoveredIndex]);
} else if (queryResults.length === 0) {
handleSubmit();
}
} else if (event.key === KeyboardKey.ARROW_DOWN) {
if (queryResults.length > 0) {
event.preventDefault();
hoveredIndex = (hoveredIndex + 1) % queryResults.length;
scrollTrigger++;
nav.move(1);
}
} else if (event.key === KeyboardKey.ARROW_UP) {
if (queryResults.length > 0) {
event.preventDefault();
hoveredIndex = hoveredIndex <= 0 ? queryResults.length - 1 : hoveredIndex - 1;
scrollTrigger++;
nav.move(-1);
}
}
}
function handleInputInput(value: string) {
hoveredIndex = -1;
if (value.trim().length > 0) {
runSearch(value);
}
}
function clearDirectory(event?: MouseEvent) {
// Stop the click from bubbling into the popover trigger and re-opening
// Stop the click from bubbling into the chip button and re-opening
// the picker on top of the now-cleared state.
event?.stopPropagation();
event?.preventDefault();
directory = null;
onChange?.(null);
closePicker();
}
// The chip is always visible; the X clears the directory (no-op when
// already empty).
function handleDismiss(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
@@ -380,105 +279,104 @@
}
function handleOpenChange(open: boolean) {
isOpen = open;
if (open) {
// Seed the search field with the current path so the user can refine it
// (or hit Enter to confirm / clear via the X icon).
inputValue = directory ?? '';
hoveredIndex = -1;
queryResults = [];
searchError = null;
void toolsStore.resolveServerHome();
searchScope = homeBase ?? HOME_TILDE;
if (inputValue.trim()) runSearch(inputValue);
} else {
cancelSearch();
// bits-ui-initiated close (Escape on the content, outside-click,
// trigger toggle) - the only path that bypasses closePicker().
search.cancel();
// bits-ui-initiated close (Escape on the content, outside-click) -
// the only path that bypasses closePicker().
onClose?.();
}
}
// Tooltips only on wider viewports - hover surfaces get in the way on
// touch / narrow layouts. Mirrors the gate used in ActionIcon.
let innerWidth = $state(0);
const showTooltip = $derived(innerWidth > DEFAULT_MOBILE_BREAKPOINT);
</script>
<div
<button
type="button"
class={[
'justify-self-start flex min-w-0 w-auto items-center gap-1 mt-1.5 py-1 px-2 backdrop-blur-2xl rounded-md',
className,
isOpen && 'w-full'
className
]}
onclick={onOpen}
{disabled}
>
<Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
<Popover.Trigger {disabled} class="flex justify-start">
<ChatFormWorkingDirectoryChip
{directory}
{homeBase}
{disabled}
{showTooltip}
onClear={handleDismiss}
<ChatFormWorkingDirectoryChip
{directory}
{homeBase}
{disabled}
{showTooltip}
onClear={handleDismiss}
/>
</button>
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
<Popover.Trigger
class="pointer-events-none absolute inset-0 opacity-0"
tabindex={-1}
aria-hidden="true"
>
<span class="sr-only">Open working directory picker</span>
</Popover.Trigger>
<Popover.Content
side="top"
align="start"
sideOffset={12}
{customAnchor}
preventScroll={false}
onkeydown={handleKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl"
>
<div class="p-2 min-h-22 flex flex-col justify-between">
<SearchInput
bind:ref={searchInputRef}
bind:value={query}
placeholder="Choose working directory"
onClose={closePicker}
class="w-full"
/>
</Popover.Trigger>
<Popover.Content
side="top"
align="start"
sideOffset={4}
class="md:max-w-3xl w-[calc(100vw-1rem)] rounded-xl border-border/50 p-0 shadow-xl md:-translate-2!"
onkeydown={handleKeydown}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<div class="p-2 min-h-28 flex flex-col justify-between">
<SearchInput
bind:ref={searchInputRef}
bind:value={inputValue}
placeholder="Choose working directory"
onInput={handleInputInput}
onClose={closePicker}
class="w-full"
{#if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
<ChatFormWorkingDirectoryResultsList
results={queryResults}
hoveredIndex={nav.hoveredIndex}
isSearching={search.isSearching}
error={searchError}
rawQuery={query}
bind:container={listContainer}
onCommit={commit}
onHover={(index) => nav.setHover(index)}
/>
{/if}
{#if inputValue.trim() && (isSearching || queryResults.length > 0 || searchError)}
<ChatFormWorkingDirectoryResultsList
results={queryResults}
{hoveredIndex}
{isSearching}
error={searchError}
rawQuery={inputValue}
bind:container={listContainer}
onCommit={commit}
onHover={(index) => (hoveredIndex = index)}
/>
{/if}
{#if pickerSupported}
<button
type="button"
class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onclick={browseNative}
>
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
<span>Browse</span>
</button>
{/if}
{#if pickerSupported}
<button
type="button"
class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
onclick={browseNative}
{#if homeBase}
<div class="-mx-2 my-2 h-px bg-border/20" aria-hidden="true"></div>
<span class="px-2 py-1.5 font-mono text-[10px]">
Searching in:
<span class="truncate text-muted-foreground/70" title={searchScope}
>{abbreviateHome(searchScope, homeBase)}</span
>
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
<span>Browse</span>
</button>
{/if}
{#if homeBase}
<div class="-mx-2 my-1 h-px bg-border/20" aria-hidden="true"></div>
<span class="px-2 py-2 font-mono text-[10px]">
Searching in:
<span class="truncate text-muted-foreground/70" title={searchScope}
>{abbreviateHome(searchScope, homeBase)}</span
>
</span>
{/if}
</div>
</Popover.Content>
</Popover.Root>
</div>
</span>
{/if}
</div>
</Popover.Content>
</Popover.Root>
<svelte:window bind:innerWidth />
@@ -1,6 +1,7 @@
<script lang="ts">
import { Folder, X } from '@lucide/svelte';
import { abbreviateWorkingDir } from '$lib/utils';
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ActionIcon } from '$lib/components/app/actions';
@@ -21,7 +22,7 @@
}: Props = $props();
const displayLabel = $derived(
directory ? abbreviateWorkingDir(directory, homeBase) : 'Select working directory'
directory ? abbreviateWorkingDir(directory, homeBase) : SET_WORKING_DIRECTORY_LABEL
);
// Full path surface: hover the abbreviated label to recall the exact directory.
const displayLabelTitle = $derived(directory ?? '');
@@ -183,8 +183,8 @@
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="bottom" />
{/if}
<div class="info my-6 grid gap-4 tabular-nums">
{#if displayedModel}
{#if displayedModel}
<div class="info my-6 grid gap-4 tabular-nums">
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
<ChatMessageAssistantModel
{displayedModel}
@@ -200,8 +200,8 @@
showMessageStats={currentConfig.showMessageStats}
/>
</div>
{/if}
</div>
</div>
{/if}
{#if message.timestamp && !editCtx.isEditing}
<ChatMessageActionIcons
@@ -164,7 +164,7 @@
? `max-height: ${MAX_HEIGHT}px;`
: 'max-height: none;'}
>
{#if currentConfig.renderUserContentAsMarkdown}
{#if !currentConfig.renderContentAsRawText}
<div bind:this={messageElement} class={isExpanded ? 'cursor-text' : ''}>
<MarkdownContent class="markdown-system-content" content={message.content} />
</div>
@@ -98,9 +98,10 @@
showSpinner || (toolUi?.icon ?? null) || !mcpServerFavicon ? null : mcpServerFavicon
);
// No subtitle while the call is in flight - the spinner already
// signals activity; only terminal states get a pill.
function subtitleFor(errorMessage?: string): string | undefined {
if (extraLiveStreaming) return 'streaming...';
if (showSpinner) return 'executing...';
if (showSpinner) return undefined;
if (errorMessage) return 'failed';
if (isStreamingCall && !isStreaming) return 'incomplete';
return undefined;
@@ -63,7 +63,7 @@
data-multiline={isMultiline ? '' : undefined}
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
>
{#if renderMarkdown && currentConfig.renderUserContentAsMarkdown}
{#if renderMarkdown && !currentConfig.renderContentAsRawText}
<div bind:this={messageElement}>
<MarkdownContent class="markdown-user-content" {content} />
</div>
@@ -41,7 +41,6 @@
let expandedStates: Record<number, boolean> = $state({});
const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean);
const showThoughtInProgress = $derived(Boolean(config().showThoughtInProgress));
const alwaysShowToolCallContent = $derived(Boolean(config().alwaysShowToolCallContent));
const showMessageStats = $derived(Boolean(config().showMessageStats));
@@ -186,7 +185,6 @@
{section}
open={isExpanded(index, section)}
{isStreaming}
{renderThinkingAsMarkdown}
{hasReasoningError}
attachments={message?.extra}
onToggle={() => toggleExpanded(index, section)}
@@ -3,6 +3,7 @@
import { CollapsibleContentBlock, MarkdownContent } from '$lib/components/app';
import { AgenticSectionType } from '$lib/enums';
import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
import { config } from '$lib/stores/settings.svelte';
import type { DatabaseMessageExtra } from '$lib/types';
import type { AgenticSection } from '$lib/utils';
@@ -10,7 +11,6 @@
section: AgenticSection;
open: boolean;
isStreaming: boolean;
renderThinkingAsMarkdown: boolean;
hasReasoningError?: boolean;
attachments?: DatabaseMessageExtra[];
onToggle?: () => void;
@@ -20,12 +20,13 @@
section,
open,
isStreaming,
renderThinkingAsMarkdown,
hasReasoningError = false,
attachments,
onToggle
}: Props = $props();
const currentConfig = config();
const REASONING_HEADER = 'Reasoning';
const REASONING_HEADER_PENDING = 'Reasoning...';
const REASONING_SUBTITLE_ERROR = 'Error';
@@ -128,7 +129,7 @@
class:is-streaming={isPending}
onscroll={handleScrollEvent}
>
{#if renderThinkingAsMarkdown}
{#if !currentConfig.renderContentAsRawText}
<MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
{:else}
<div
+12 -8
View File
@@ -266,9 +266,9 @@ export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileIn
export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte';
/**
* Auto-resizing textarea with IME composition support. Automatically adjusts
* height based on content. Handles IME input correctly (waits for composition
* end before processing Enter key). Exposes focus() and resetHeight() methods.
* Auto-resizing textarea with IME composition support. Mention links stay
* plain markdown text in the input; the chip rendering happens in the
* message view via the rehype file-badge plugin.
*/
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
@@ -384,11 +384,15 @@ export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPi
export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
/**
* **ChatFormPickers** - Chat input picker container
*
* Container component that hosts the MCP prompt and file mention pickers.
* Manages shared state, keyboard navigation, and coordination between the two
* picker interfaces. Used within ChatForm.
* `/`-triggered slash-command picker. Lists the available slash commands
* (`/prompt`, `/cwd`, `/model`) filtered by the typed query; selection
* hands the command to the parent for dispatch.
*/
export { default as ChatFormCommandPicker } from './ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte';
/**
* Hosts the chat-form pickers (slash-command, MCP prompt, file mention)
* and delegates keyboard events to the active one.
*/
export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte';