* 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
idxThemeStyle?: number;
idxCodeBlock?: number;
- // File System Access API - missing from older DOM lib versions.
- // Used by ChatFormWorkingDirectory's native folder picker. Feature availability
- // is gated at runtime via `typeof window.showDirectoryPicker === 'function'`.
- showDirectoryPicker: (options?: {
+ // File System Access API - not in the DOM lib and unavailable in some browsers
+ showDirectoryPicker?: (options?: {
id?: string;
mode?: 'read' | 'readwrite';
startIn?: FileSystemHandle | string;
INPUT_CLASSES,
SETTING_CONFIG_DEFAULT,
INITIAL_FILE_SIZE,
- PROMPT_CONTENT_SEPARATOR,
- PROMPT_TRIGGER_PREFIX
+ PROMPT_CONTENT_SEPARATOR
} from '$lib/constants';
import {
ContentPartType,
} 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,
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);
}
}
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() {
}
}
- 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;
}
setTimeout(() => {
- textareaRef?.focus();
+ inputRef?.focus();
}, 10);
return;
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 = {
uploadedFiles = [...uploadedFiles, placeholder];
onUploadedFilesChange?.(uploadedFiles);
- textareaRef?.focus();
+ inputRef?.focus();
}
function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) {
onUploadedFilesChange?.(uploadedFiles);
}
- function handlePromptPickerClose() {
- isPromptPickerOpen = false;
- promptSearchQuery = '';
- textareaRef?.focus();
- }
-
- function handleMentionPickerClose() {
- if (isMentionPickerOpen) {
- const cursor = textareaRef?.getCaretOffset() ?? value.length;
- mentionDismissedSnapshot = takeMentionDismissSnapshot(value, cursor);
- }
- isMentionPickerOpen = false;
- mentionQuery = '';
- refocusInput();
+ // 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());
}
- // 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;
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);
});
}
>
<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}
<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}
/>
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
- onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
+ onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
/>
</div>
{#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}
--- /dev/null
+<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>
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);
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = (selectedIndex + 1) % filteredPrompts.length;
+ scrollTrigger++;
}
return true;
event.preventDefault();
if (filteredPrompts.length > 0) {
selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1;
+ scrollTrigger++;
}
return true;
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)}
<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;
}
let {
+ isCommandPickerOpen,
+ commandQuery,
+ commands = [],
+ onCommandPickerClose,
+ onCommandSelect,
isPromptPickerOpen,
promptSearchQuery,
isMentionPickerOpen,
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;
}
}
</script>
+<ChatFormCommandPicker
+ bind:this={commandPickerRef}
+ isOpen={isCommandPickerOpen ?? false}
+ query={commandQuery ?? ''}
+ {commands}
+ onClose={onCommandPickerClose ?? (() => {})}
+ onSelect={onCommandSelect ?? (() => {})}
+/>
+
<ChatFormPickerMcpPrompts
bind:this={promptPickerRef}
isOpen={isPromptPickerOpen}
onMount(() => {
if (textareaElement) {
autoResizeTextarea(textareaElement);
- textareaElement.focus();
+ textareaElement.focus({ preventScroll: true });
}
});
- // Expose the textarea element for external access
export function getElement() {
return textareaElement;
}
}
}
- // 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;
<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';
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
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' });
- });
- });
-
- function cancelSearch() {
- searchController?.abort();
- searchSeq++;
- isSearching = false;
- }
-
- // 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) {
+ if (!isOpen) return;
+ const q = query.trim();
+ nav.reset(-1);
+ if (q) {
+ search.run(q);
+ } else {
+ search.cancel();
queryResults = [];
searchError = null;
- isSearching = false;
- hoveredIndex = -1;
+ nav.reset(-1);
searchScope = homeBase ?? HOME_TILDE;
- return;
}
+ });
- cancelSearch();
- const controller = new AbortController();
- searchController = controller;
- const mySeq = ++searchSeq;
+ useScrollActiveRow({
+ getTrigger: () => nav.scrollTrigger,
+ getContainer: () => listContainer,
+ getIndex: () => nav.hoveredIndex,
+ getCount: () => queryResults.length,
+ dataIndex: 'result'
+ });
- const pathQuery = splitPathQuery(trimmed);
+ let searchScope = $state(HOME_TILDE);
- 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;
}
- }
- 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;
+ 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);
+ }
}
- }
-
- // 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();
}
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, {
}
function handleSubmit() {
- const value = inputValue.trim();
+ const value = query.trim();
if (!value) {
closePicker();
return;
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();
}
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 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}
+ {#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 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 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 />
<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';
}: 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 ?? '');
<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}
showMessageStats={currentConfig.showMessageStats}
/>
</div>
- {/if}
- </div>
+ </div>
+ {/if}
{#if message.timestamp && !editCtx.isEditing}
<ChatMessageActionIcons
? `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>
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;
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>
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));
{section}
open={isExpanded(index, section)}
{isStreaming}
- {renderThinkingAsMarkdown}
{hasReasoningError}
attachments={message?.extra}
onToggle={() => toggleExpanded(index, section)}
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';
section: AgenticSection;
open: boolean;
isStreaming: boolean;
- renderThinkingAsMarkdown: boolean;
hasReasoningError?: boolean;
attachments?: DatabaseMessageExtra[];
onToggle?: () => void;
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';
class:is-streaming={isPending}
onscroll={handleScrollEvent}
>
- {#if renderThinkingAsMarkdown}
+ {#if !currentConfig.renderContentAsRawText}
<MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
{:else}
<div
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';
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';
<script lang="ts">
- import { ChevronDown, Loader2, Package } from '@lucide/svelte';
+ import { ChevronDown, Loader2 } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
+ import { MODEL_SELECTOR_ICON } from '$lib/constants';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
import { modelLoadFraction } from '$lib/utils';
}: Props = $props();
let isOpen = $state(false);
- let highlightedIndex = $state<number>(-1);
+ let highlightedId = $state<string | null>(null);
const ms = useModelsSelector({
currentModel: () => currentModel,
onModelChange: () => onModelChange,
onOpenChange: (open) => {
isOpen = open;
- highlightedIndex = -1;
+ highlightedId = null;
}
});
$effect(() => {
void ms.searchTerm;
- highlightedIndex = -1;
+ highlightedId = null;
});
+ // Focus the dropdown's search box without scrolling the page. bits-ui
+ // auto-focuses the opened content by default, which can yank the page
+ // scroll; we prevent that on the Content and refocus the search here.
+ $effect(() => {
+ if (!isOpen) return;
+
+ requestAnimationFrame(() => {
+ const search = document.querySelector<HTMLElement>(
+ '[data-slot="dropdown-menu-content"] input'
+ );
+
+ search?.focus({ preventScroll: true });
+ });
+ });
+
+ // Keyboard navigation follows the on-screen row order, not the flat option list order.
+ let visualOrder = $derived.by(() => {
+ const order: string[] = [];
+
+ for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
+ for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
+ for (const group of ms.groupedFilteredOptions.available) {
+ for (const item of group.items) order.push(item.option.id);
+ }
+
+ return order;
+ });
+
+ let highlightedIndex = $derived(highlightedId ? visualOrder.indexOf(highlightedId) : -1);
+
+ function moveHighlight(direction: 1 | -1) {
+ const len = visualOrder.length;
+ if (len === 0) {
+ highlightedId = null;
+ return;
+ }
+
+ let index = highlightedIndex;
+ if (index === -1) {
+ index = direction === 1 ? 0 : len - 1;
+ } else {
+ index = (index + direction + len) % len;
+ }
+
+ highlightedId = visualOrder[index];
+ }
+
+ // Alt+Enter only unloads and keeps the dropdown open.
+ async function handleModelKeyAction(modelId: string, unload: boolean) {
+ if (!unload) {
+ void ms.handleSelect(modelId);
+ return;
+ }
+
+ const model = routerModels().find((m) => m.id === modelId);
+ const status = model?.status?.value as ServerModelStatus | undefined;
+
+ if (status === ServerModelStatus.LOADING) return;
+
+ await modelsStore.unloadModel(modelId);
+ }
+
export function open() {
ms.handleOpenChange(true);
}
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
-
- if (ms.filteredOptions.length === 0) return;
-
- if (highlightedIndex === -1 || highlightedIndex === ms.filteredOptions.length - 1) {
- highlightedIndex = 0;
- } else {
- highlightedIndex += 1;
- }
+ moveHighlight(1);
} else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
-
- if (ms.filteredOptions.length === 0) return;
-
- if (highlightedIndex === -1 || highlightedIndex === 0) {
- highlightedIndex = ms.filteredOptions.length - 1;
- } else {
- highlightedIndex -= 1;
- }
+ moveHighlight(-1);
} else if (event.key === KeyboardKey.ENTER) {
event.preventDefault();
- if (highlightedIndex >= 0 && highlightedIndex < ms.filteredOptions.length) {
- const option = ms.filteredOptions[highlightedIndex];
-
- ms.handleSelect(option.id);
- } else if (ms.filteredOptions.length > 0) {
- highlightedIndex = 0;
+ if (highlightedId) {
+ void handleModelKeyAction(highlightedId, event.altKey);
+ } else if (visualOrder.length > 0) {
+ highlightedId = visualOrder[0];
}
}
}
]}
style="max-width: min(calc(100cqw - 10rem), 20rem)"
>
- <Package class="h-3.5 w-3.5 shrink-0" />
+ <MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
</span>
{:else}
<p class="text-xs text-muted-foreground">No models available.</p>
]}
disabled={disabled || ms.updating}
>
- <Package class="h-3.5 w-3.5 shrink-0" />
+ <MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption}
<ModelId
<DropdownMenu.Content
align="end"
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
+ onOpenAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuSearchable
searchValue={ms.searchTerm}
{/if}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
- {@const { option, flatIndex } = item}
+ {@const { option } = item}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
- {@const isHighlighted = flatIndex === highlightedIndex}
+ {@const isHighlighted = option.id === highlightedId}
{@const isFav = ms.isFavorite(option.model)}
<ModelsSelectorOption
{hideOrgName}
onSelect={ms.handleSelect}
onInfoClick={ms.handleInfoClick}
- onMouseEnter={() => (highlightedIndex = flatIndex)}
+ onMouseEnter={() => (highlightedId = option.id)}
onKeyDown={(event) => {
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
event.preventDefault();
- ms.handleSelect(option.id);
+ void handleModelKeyAction(option.id, event.altKey);
}
}}
/>
onclick={() => ms.handleOpenChange(true)}
disabled={disabled || ms.updating}
>
- <Package class="h-3.5 w-3.5 shrink-0" />
+ <MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption}
<ModelId
<div
class={[
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
- 'cursor-pointer hover:bg-muted focus:bg-muted',
- (isSelected || isHighlighted) && 'bg-accent text-accent-foreground',
- !(isSelected || isHighlighted) && 'hover:bg-accent hover:text-accent-foreground',
+ 'cursor-pointer',
+ isSelected && 'bg-accent/50 text-accent-foreground',
+ isHighlighted && 'bg-accent',
+ !isSelected && !isHighlighted && 'hover:bg-muted',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
]}
role="option"
const numValue = Number(processedConfig[field]);
if (!isNaN(numValue)) {
if ((POSITIVE_INTEGER_FIELDS as readonly string[]).includes(field)) {
- processedConfig[field] = Math.max(1, Math.round(numValue));
+ const entryByMinMax = SETTINGS_CHAT_SECTIONS.flatMap(
+ (section) => section.fields ?? []
+ ).find((entry) => entry.key === field);
+ const lo = entryByMinMax?.min ?? 1;
+ const hi = entryByMinMax?.max ?? Number.POSITIVE_INFINITY;
+ processedConfig[field] = Math.max(lo, Math.min(hi, Math.round(numValue)));
} else {
processedConfig[field] = numValue;
}
<Input
id={field.key}
type={field.isPositiveInteger ? 'number' : 'text'}
- {...field.isPositiveInteger ? { min: '1', step: '1' } : {}}
+ {...field.isPositiveInteger
+ ? {
+ min: String(field.min ?? 1),
+ step: '1',
+ ...(field.max != null ? { max: String(field.max) } : {})
+ }
+ : {}}
value={currentValue}
oninput={(e) => onConfigChange(field.key, e.currentTarget.value)}
placeholder={currentModelParams[field.key] != null
? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}`
- : ''}
+ : (field.placeholder ?? '')}
class="w-full {isCustomRealTime ? 'pr-8' : ''}"
/>
{#if isCustomRealTime}
--- /dev/null
+import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants/working-directory';
+import { ChatFormCommandAction } from '$lib/enums';
+import type { ChatFormCommand } from '$lib/types';
+
+interface ChatCommandsOptions {
+ /** Gates `/model`. */
+ showModelSelector: boolean;
+ /** Gates `/prompt`. */
+ hasPrompts: () => boolean;
+ /** Gates `/cwd`. */
+ hasBuiltinTools: () => boolean;
+}
+
+/**
+ * The slash commands surfaced by the `/` command picker, in display order.
+ *
+ * Availability is supplied as predicates rather than store imports: this
+ * module is re-exported through the `$lib/constants` barrel, and importing
+ * stores at module load would create a circular dependency (the stores
+ * themselves import from `$lib/constants`).
+ */
+export function getChatCommands(options: ChatCommandsOptions): ChatFormCommand[] {
+ return [
+ {
+ name: 'prompt',
+ description: 'Insert an MCP prompt',
+ action: ChatFormCommandAction.PROMPT,
+ disabled: !options.hasPrompts()
+ },
+ {
+ name: 'cwd',
+ description: SET_WORKING_DIRECTORY_LABEL,
+ keywords: ['current working directory'],
+ action: ChatFormCommandAction.CWD,
+ disabled: !options.hasBuiltinTools()
+ },
+ {
+ name: 'model',
+ description: 'Select model',
+ action: ChatFormCommandAction.MODEL,
+ disabled: !options.showModelSelector
+ }
+ ];
+}
export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
export const DIALOG_SUBMENU_CONTENT = 'w-60';
+/** Selects the chat-form input to restore focus after model actions. */
+export const CHAT_INPUT_FOCUS_SELECTOR = '[data-slot="input-area"] textarea';
+
/** Default Tailwind size class for inline icon components (lucide, etc.). */
export const ICON_CLASS_DEFAULT = 'h-4 w-4';
export * from './built-in-tools';
export * from './cache';
export * from './chat-form';
+export * from './chat-commands';
export * from './cli-flags';
export * from './code-blocks';
export * from './icons';
SHOW_AGENTIC_TURN_STATS: 'showAgenticTurnStats',
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
- RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
+ RENDER_CONTENT_AS_RAW_TEXT: 'renderContentAsRawText',
DISABLE_AUTO_SCROLL: 'disableAutoScroll',
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
SHOW_BUILD_VERSION: 'showBuildVersion',
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
- RENDER_THINKING_AS_MARKDOWN: 'renderThinkingAsMarkdown',
MENTION_SEARCH_MAX_DEPTH: 'mentionSearchMaxDepth',
// Sampling
TEMPERATURE: 'temperature',
section: SETTINGS_SECTION_SLUGS.DISPLAY
},
{
- key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
- label: 'Render user content as Markdown',
- help: 'Render user messages using markdown formatting in the chat.',
+ key: SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT,
+ label: 'Render content as raw text',
+ help: 'Display user, system and thinking content as plain text instead of formatted Markdown. Markdown is the default so that @-mention badges render in sent messages.',
defaultValue: false,
type: SettingsFieldType.CHECKBOX,
section: SETTINGS_SECTION_SLUGS.DISPLAY
},
- {
- key: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN,
- label: 'Render thinking as Markdown',
- help: 'Render the reasoning/thinking block content as formatted Markdown instead of plain text.',
- defaultValue: true,
- type: SettingsFieldType.CHECKBOX,
- section: SETTINGS_SECTION_SLUGS.DISPLAY
- },
{
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
label: 'Use full height code blocks',
type: s.type,
isExperimental: s.isExperimental,
isPositiveInteger: s.isPositiveInteger,
+ placeholder: s.placeholder,
+ min: s.min,
+ max: s.max,
dependsOn: s.dependsOn,
help: s.help,
options: s.options,
-import { Search, Settings, SquarePen } from '@lucide/svelte';
+import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
import type { Component } from 'svelte';
import { ROUTES } from './routes';
export const FORK_TREE_DEPTH_PADDING = 8;
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
+/** Icon used for the model selector and the `/model` slash command. */
+export const MODEL_SELECTOR_ICON = Package;
+
export const ICON_STRIP_TRANSITION_DURATION = 150;
export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
export const GLOB_WILDCARD = '*';
+/** Label shown for the working-directory picker / `/cwd` slash command. */
+export const SET_WORKING_DIRECTORY_LABEL = 'Set working directory';
+
/** Character that starts and ends a glob character-class fragment. */
export const GLOB_RANGE_OPEN = '[';
export const GLOB_RANGE_CLOSE = ']';
PAGES = 'pages'
}
+export enum ChatFormCommandAction {
+ PROMPT = 'prompt',
+ CWD = 'cwd',
+ MODEL = 'model'
+}
+
export enum FileMentionEntryType {
FILE = 'file',
DIRECTORY = 'directory'
MessageType,
PdfViewMode,
ReasoningFormat,
+ ChatFormCommandAction,
FileMentionEntryType
} from './chat.enums';
--- /dev/null
+import { getChatCommands, PROMPT_TRIGGER_PREFIX } from '$lib/constants';
+import { ChatFormCommandAction, KeyboardKey } from '$lib/enums';
+import type { ChatFormCommand } from '$lib/types';
+import {
+ findCommandToken,
+ findMentionToken,
+ takeCommandDismissSnapshot,
+ takeMentionDismissSnapshot,
+ type CommandDismissSnapshot,
+ type MentionDismissSnapshot
+} from '$lib/utils';
+
+/** Dependencies injected as getters so the hook stays free of store circular imports. */
+export interface UseChatFormPickersOptions {
+ getValue: () => string;
+ /** Also fires the form's onChange. */
+ setValue: (value: string) => void;
+ /** Undefined when unmounted. */
+ getCaretOffset: () => number | undefined;
+ setCaretOffset: (offset: number) => void;
+ focusInput: () => void;
+ /** Gates `/model`. */
+ getShowModelSelector: () => boolean;
+ /** Gates `/prompt`. */
+ hasPrompts: () => boolean;
+ /** Gates `/cwd`. */
+ hasBuiltinTools: () => boolean;
+ getCwd: () => string | null;
+ /** Mention search fallback scope. */
+ getServerHome: () => string | null;
+ openModelSelector: () => void;
+ /** Delegate a keydown to the mounted pickers component, if any. */
+ getPickersRef: () => { handleKeydown(event: KeyboardEvent): boolean } | undefined;
+}
+
+/**
+ * Chat-form picker state and the `/`+`@` routing that drives them.
+ * Owns open/query state, dismiss snapshots and slash-command dispatch;
+ * textarea/caret/attachment handling stays in the chat form.
+ */
+export function useChatFormPickers(opts: UseChatFormPickersOptions) {
+ let isCommandPickerOpen = $state(false);
+ let commandQuery = $state('');
+ let isPromptPickerOpen = $state(false);
+ let promptSearchQuery = $state('');
+ let isMentionPickerOpen = $state(false);
+ let mentionQuery = $state('');
+ let isWorkingDirectoryPickerOpen = $state(false);
+ let workingDirectoryQuery = $state('');
+
+ // Last dismissed `@`-mention token; while intact, the picker does not
+ // reopen, so an escaped `@<query>` stays literal until edited.
+ let mentionDismissedSnapshot: MentionDismissSnapshot | null = null;
+
+ // Same dismissal contract for the `/`-command token.
+ let commandDismissedSnapshot: CommandDismissSnapshot | null = null;
+
+ // Fall back to the server home so the picker still finds matches
+ // before a cwd is set.
+ const mentionScopePath = $derived(opts.getCwd() ?? opts.getServerHome() ?? null);
+
+ const availableCommands = $derived(
+ getChatCommands({
+ showModelSelector: opts.getShowModelSelector(),
+ hasPrompts: opts.hasPrompts,
+ hasBuiltinTools: opts.hasBuiltinTools
+ })
+ );
+
+ // Dispatch a slash command picked from the list: consume the token and
+ // open the target picker, seeding its search with `args`. Runs only on
+ // explicit selection (Enter/click), so the buffer is never cleared
+ // mid-typing.
+ function dispatchCommand(command: ChatFormCommand, args: string) {
+ isCommandPickerOpen = false;
+ commandQuery = '';
+
+ switch (command.action) {
+ case ChatFormCommandAction.PROMPT:
+ isWorkingDirectoryPickerOpen = false;
+ opts.setValue('');
+ isPromptPickerOpen = true;
+ promptSearchQuery = args.trim();
+ break;
+ case ChatFormCommandAction.CWD: {
+ // Keep `/cwd <args>` in the input so the search field and the
+ // token stay two-way bound; normalize partial tokens (`/cw foo`).
+ const trimmed = args.trim();
+ const newValue = `/cwd ${trimmed}`;
+ if (opts.getValue() !== newValue) {
+ opts.setValue(newValue);
+ queueMicrotask(() => opts.setCaretOffset(newValue.length));
+ }
+ workingDirectoryQuery = trimmed;
+ isWorkingDirectoryPickerOpen = true;
+ break;
+ }
+ case ChatFormCommandAction.MODEL:
+ isWorkingDirectoryPickerOpen = false;
+ opts.setValue('');
+ opts.openModelSelector();
+ break;
+ }
+ }
+
+ function handleInput() {
+ const value = opts.getValue();
+ const cursor = opts.getCaretOffset() ?? value.length;
+
+ if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
+ isMentionPickerOpen = false;
+ mentionQuery = '';
+ isPromptPickerOpen = false;
+ promptSearchQuery = '';
+
+ const token = findCommandToken(value);
+ if (!token) {
+ isCommandPickerOpen = false;
+ commandQuery = '';
+ return;
+ }
+
+ // While the `/cwd` picker is open the token doubles as its search
+ // field: keep the two in sync instead of re-dispatching.
+ if (isWorkingDirectoryPickerOpen) {
+ isCommandPickerOpen = false;
+ commandQuery = '';
+ if (token.name === 'cwd') {
+ workingDirectoryQuery = token.args.trim();
+ } else {
+ isWorkingDirectoryPickerOpen = false;
+ workingDirectoryQuery = '';
+ }
+ return;
+ }
+
+ // Dismissed token stays literal until it changes.
+ const isDismissedSticky =
+ commandDismissedSnapshot !== null &&
+ commandDismissedSnapshot.name === token.name &&
+ commandDismissedSnapshot.args === token.args;
+
+ if (isDismissedSticky) {
+ isCommandPickerOpen = false;
+ commandQuery = '';
+ return;
+ }
+
+ // Commands dispatch only on explicit selection (Enter/click),
+ // never mid-typing: `/model is broken` is prose until the user
+ // picks the command from the list.
+ if (availableCommands.length > 0) {
+ isCommandPickerOpen = true;
+ commandQuery = token.name;
+ } else {
+ isCommandPickerOpen = false;
+ commandQuery = '';
+ }
+ return;
+ }
+
+ isCommandPickerOpen = false;
+ commandQuery = '';
+ if (commandDismissedSnapshot !== null) {
+ commandDismissedSnapshot = null;
+ }
+ if (isWorkingDirectoryPickerOpen) {
+ isWorkingDirectoryPickerOpen = false;
+ }
+
+ const token = findMentionToken(value, cursor);
+
+ if (token) {
+ // Dismissed token stays literal: don't reopen until it changes.
+ const isDismissedSticky =
+ mentionDismissedSnapshot !== null &&
+ mentionDismissedSnapshot.start === token.start &&
+ mentionDismissedSnapshot.query === token.query;
+
+ if (!isDismissedSticky) {
+ // Only search once a char follows `@`; a bare `@` is a no-op
+ // (otherwise the picker flashes an empty hint on re-type).
+ if (token.query.length > 0) {
+ mentionDismissedSnapshot = null;
+ isMentionPickerOpen = true;
+ mentionQuery = token.query;
+ isPromptPickerOpen = false;
+ promptSearchQuery = '';
+ return;
+ }
+ }
+ }
+
+ isPromptPickerOpen = false;
+ promptSearchQuery = '';
+ isMentionPickerOpen = false;
+ mentionQuery = '';
+
+ // Token gone or changed: reset the snapshot so a fresh `@` reopens.
+ if (mentionDismissedSnapshot !== null && !token) {
+ mentionDismissedSnapshot = null;
+ }
+ }
+
+ function handleKeydown(event: KeyboardEvent): boolean {
+ if (opts.getPickersRef()?.handleKeydown(event)) {
+ return true;
+ }
+
+ if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
+ isPromptPickerOpen = false;
+ promptSearchQuery = '';
+ return true;
+ }
+
+ return false;
+ }
+
+ function handleCommandSelect(command: ChatFormCommand) {
+ // Dispatch on the live token so typed args seed the target picker.
+ const token = findCommandToken(opts.getValue());
+ dispatchCommand(command, token?.args ?? '');
+ }
+
+ // Picker dismissed: snapshot the live token so it stays literal until
+ // deleted or retyped.
+ function handleCommandPickerClose() {
+ if (isCommandPickerOpen) {
+ commandDismissedSnapshot = takeCommandDismissSnapshot(opts.getValue());
+ }
+ isCommandPickerOpen = false;
+ commandQuery = '';
+ // Target picker manages its own focus: don't yank it back to the input.
+ if (!isPromptPickerOpen && !isMentionPickerOpen && !isWorkingDirectoryPickerOpen) {
+ opts.focusInput();
+ }
+ }
+
+ // Same dismissal snapshot for the mention token.
+ function handleMentionPickerClose() {
+ if (isMentionPickerOpen) {
+ const cursor = opts.getCaretOffset() ?? opts.getValue().length;
+ mentionDismissedSnapshot = takeMentionDismissSnapshot(opts.getValue(), cursor);
+ }
+ isMentionPickerOpen = false;
+ mentionQuery = '';
+ opts.focusInput();
+ }
+
+ function handlePromptPickerClose() {
+ isPromptPickerOpen = false;
+ promptSearchQuery = '';
+ opts.focusInput();
+ }
+
+ function handleWorkingDirectoryOpen() {
+ workingDirectoryQuery = opts.getCwd() ?? '';
+ isWorkingDirectoryPickerOpen = true;
+ }
+
+ function handleWorkingDirectoryClose() {
+ isWorkingDirectoryPickerOpen = false;
+ workingDirectoryQuery = '';
+ opts.focusInput();
+ }
+
+ // Two-way bind the text after `/cwd ` and the picker search input; the
+ // reverse direction is handled by handleInput.
+ $effect(() => {
+ if (!isWorkingDirectoryPickerOpen) return;
+ const value = opts.getValue();
+ const token = findCommandToken(value);
+ if (!token || token.name !== 'cwd') return;
+ const newValue = `/cwd ${workingDirectoryQuery}`;
+ if (newValue === value) return;
+ opts.setValue(newValue);
+ queueMicrotask(() => opts.setCaretOffset(newValue.length));
+ });
+
+ return {
+ get isCommandPickerOpen() {
+ return isCommandPickerOpen;
+ },
+ set isCommandPickerOpen(v: boolean) {
+ isCommandPickerOpen = v;
+ },
+ get commandQuery() {
+ return commandQuery;
+ },
+ set commandQuery(v: string) {
+ commandQuery = v;
+ },
+ get isPromptPickerOpen() {
+ return isPromptPickerOpen;
+ },
+ set isPromptPickerOpen(v: boolean) {
+ isPromptPickerOpen = v;
+ },
+ get promptSearchQuery() {
+ return promptSearchQuery;
+ },
+ set promptSearchQuery(v: string) {
+ promptSearchQuery = v;
+ },
+ get isMentionPickerOpen() {
+ return isMentionPickerOpen;
+ },
+ set isMentionPickerOpen(v: boolean) {
+ isMentionPickerOpen = v;
+ },
+ get mentionQuery() {
+ return mentionQuery;
+ },
+ set mentionQuery(v: string) {
+ mentionQuery = v;
+ },
+ get isWorkingDirectoryPickerOpen() {
+ return isWorkingDirectoryPickerOpen;
+ },
+ set isWorkingDirectoryPickerOpen(v: boolean) {
+ isWorkingDirectoryPickerOpen = v;
+ },
+ get workingDirectoryQuery() {
+ return workingDirectoryQuery;
+ },
+ set workingDirectoryQuery(v: string) {
+ workingDirectoryQuery = v;
+ },
+ get availableCommands() {
+ return availableCommands;
+ },
+ get mentionScopePath() {
+ return mentionScopePath;
+ },
+ handleInput,
+ // True when a picker consumed the event, so the form skips submit.
+ handleKeydown,
+ dispatchCommand,
+ handleCommandSelect,
+ handleCommandPickerClose,
+ handleMentionPickerClose,
+ handlePromptPickerClose,
+ handleWorkingDirectoryOpen,
+ handleWorkingDirectoryClose,
+ openPromptPicker() {
+ isPromptPickerOpen = true;
+ },
+ closePromptPicker() {
+ isPromptPickerOpen = false;
+ promptSearchQuery = '';
+ }
+ };
+}
+
+export type UseChatFormPickersReturn = ReturnType<typeof useChatFormPickers>;
singleModelName
} from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
+import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import type { ModelOption } from '$lib/types/models';
handleOpenChange(false);
requestAnimationFrame(() => {
- const textarea = document.querySelector<HTMLTextAreaElement>(
- '[data-slot="chat-form"] textarea'
- );
+ const input = document.querySelector<HTMLElement>(CHAT_INPUT_FOCUS_SELECTOR);
- textarea?.focus({ preventScroll: true });
+ input?.focus({ preventScroll: true });
});
}
...savedVal
};
+ // Migrate the legacy render keys into `renderContentAsRawText`
+ // (inverted semantics: the old keys opted INTO markdown). Any
+ // explicit raw-text preference wins when the legacy keys disagree.
+ const LEGACY_MARKDOWN_KEYS = ['renderUserContentAsMarkdown', 'renderThinkingAsMarkdown'];
+ const LEGACY_RAW_TEXT_KEY = 'renderUserContentAsRawText'; // this branch's intermediate key
+ const legacyKeys = [...LEGACY_MARKDOWN_KEYS, LEGACY_RAW_TEXT_KEY].filter(
+ (key) => key in savedVal
+ );
+ if (legacyKeys.length > 0) {
+ if (!(SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT in savedVal)) {
+ if (LEGACY_RAW_TEXT_KEY in savedVal) {
+ this.config[SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT] = savedVal[LEGACY_RAW_TEXT_KEY];
+ } else {
+ this.config[SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT] = LEGACY_MARKDOWN_KEYS.filter(
+ (key) => key in savedVal
+ ).some((key) => savedVal[key] === false);
+ }
+ }
+ for (const key of legacyKeys) {
+ delete (this.config as Record<string, unknown>)[key];
+ }
+ this.saveConfig();
+ }
+
// Default sendOnEnter to false on mobile when the user has no saved preference
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
if (isMobile.current) {
-import type { ErrorDialogType, FileMentionEntryType } from '$lib/enums';
+import type { ChatFormCommandAction, ErrorDialogType, FileMentionEntryType } from '$lib/enums';
import type { ApiChatCompletionToolCall } from './api';
import type { DatabaseMessage, DatabaseMessageExtra } from './database';
name: string;
type: FileMentionEntryType;
}
+
+/**
+ * A slash command surfaced by the `/` command picker. `disabled` marks a
+ * command whose backing capability is unavailable (e.g. `/prompt` when no
+ * MCP server exposes prompts): visible but greyed out and not selectable.
+ */
+export interface ChatFormCommand {
+ name: string;
+ description: string;
+ /** Extra search terms that should match this command in the picker. */
+ keywords?: string[];
+ action: ChatFormCommandAction;
+ disabled: boolean;
+}
LiveGenerationStats,
AttachmentDisplayItemsOptions,
FileProcessingResult,
- FileMentionEntry
+ FileMentionEntry,
+ ChatFormCommand
} from './chat.d';
// Database types
--- /dev/null
+/**
+ * Slash-command token detection for the chat form. Valid only at offset 0.
+ */
+export function findCommandToken(
+ value: string
+): { name: string; args: string; end: number } | null {
+ if (!value.startsWith('/')) return null;
+
+ const rest = value.slice(1);
+ const spaceIdx = rest.search(/\s/);
+ const name = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
+ const args = spaceIdx === -1 ? '' : rest.slice(spaceIdx + 1);
+
+ return { name, args, end: value.length };
+}
+
+/**
+ * Stable signature of a slash-command token for use as a "dismissed"
+ * marker: while the picker is closed and this exact token is still intact,
+ * the picker does not re-open on in-token edits.
+ */
+export interface CommandDismissSnapshot {
+ name: string;
+ args: string;
+}
+
+export function takeCommandDismissSnapshot(value: string): CommandDismissSnapshot | null {
+ const token = findCommandToken(value);
+ if (!token) return null;
+ return { name: token.name, args: token.args };
+}
type MentionDismissSnapshot
} from './mention-token';
+// Slash-command token detection (for the `/`-triggered command picker)
+export {
+ findCommandToken,
+ takeCommandDismissSnapshot,
+ type CommandDismissSnapshot
+} from './command-token';
+
// Mention-chip visual contract shared by the rehype file-badge plugin,
// plus the `[name](file://...)` link helpers the mention picker splices in
export {
export function modelLoadFraction(progress: ModelLoadProgress | null): number {
if (!progress) return 0;
- const { stages, current, value } = progress;
+ // The server may emit a progress event before the stage plan is known, so
+ // `stages` can be absent. Fall back to the raw value in that case.
+ const { stages = [], current, value } = progress;
const tailCount = Math.max(stages.length - 1, 0);
const textCeiling = 1 - tailCount * MODEL_LOAD_TAIL_SHARE;
const idx = stages.indexOf(current);
if (!progress) return null;
const label = modelLoadStageLabel(progress.current);
+ if (!label) return null;
+
return `${label} ${Math.round(modelLoadFraction(progress) * 100)}%`;
}
--- /dev/null
+// Guards the @-mention picker's file_glob_search gate: when the server
+// does not expose the tool (started without --tools) or the user disabled
+// it, the picker still opens but explains why instead of firing searches
+// that would only fail with "Search failed".
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import { tick } from 'svelte';
+import ChatFormMentionPicker from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
+import { toolsStore } from '$lib/stores/tools.svelte';
+import { BuiltInTool } from '$lib/enums';
+import { DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY } from '$lib/constants';
+import type { OpenAIToolDefinition } from '$lib/types';
+
+const FILE_SEARCH_DEF: OpenAIToolDefinition = {
+ type: 'function',
+ function: { name: BuiltInTool.FILE_GLOB_SEARCH, description: '', parameters: {} }
+};
+
+const FILE_SEARCH_KEY = `builtin:${BuiltInTool.FILE_GLOB_SEARCH}`;
+
+// The store keeps its builtin tool list private; tests inject it through
+// the reactive field so the derived gates recompute.
+function setBuiltinTools(defs: OpenAIToolDefinition[]) {
+ (toolsStore as unknown as { _builtinTools: OpenAIToolDefinition[] })._builtinTools = defs;
+}
+
+function renderPicker() {
+ return render(ChatFormMentionPicker, {
+ isOpen: true,
+ query: 'main',
+ onClose: () => {},
+ onSelect: () => {}
+ });
+}
+
+afterEach(() => {
+ setBuiltinTools([]);
+ toolsStore.setToolEnabled(FILE_SEARCH_KEY, true);
+ localStorage.removeItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
+});
+
+describe('ChatFormMentionPicker file_glob_search gate', () => {
+ it('explains that file search is unavailable when the server has no tools', async () => {
+ setBuiltinTools([]);
+ renderPicker();
+ await tick();
+
+ expect(document.body.textContent).toContain(
+ 'File search is unavailable on this server (started without --tools)'
+ );
+ });
+
+ it('explains that file search must be enabled when the user disabled it', async () => {
+ setBuiltinTools([FILE_SEARCH_DEF]);
+ toolsStore.setToolEnabled(FILE_SEARCH_KEY, false);
+ renderPicker();
+ await tick();
+
+ expect(document.body.textContent).toContain(
+ 'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions'
+ );
+ });
+});
--- /dev/null
+// Guards the slash-command dispatch contract: commands dispatch only on
+// explicit selection (Enter/click in the picker), never mid-typing.
+// Typing `/model is broken` is prose until the command is picked - the
+// buffer must survive; only an actual selection consumes the token.
+
+import { describe, it, expect } from 'vitest';
+import { render } from 'vitest-browser-svelte';
+import { tick } from 'svelte';
+import ChatFormPickersHarness from './components/ChatFormPickersHarness.svelte';
+
+describe('slash command dispatch', () => {
+ it('does not dispatch or clear the buffer when a space follows the name', async () => {
+ const screen = render(ChatFormPickersHarness);
+ await tick();
+
+ screen.component.type('/model is broken');
+ await tick();
+
+ const pickers = screen.component.getPickers();
+ expect(screen.component.getValue()).toBe('/model is broken');
+ expect(screen.component.getCalls()).not.toContain('openModelSelector');
+ expect(screen.component.getCalls().some((c) => c.startsWith('setValue:'))).toBe(false);
+ expect(pickers.isCommandPickerOpen).toBe(true);
+ expect(pickers.commandQuery).toBe('model');
+ });
+
+ it('dispatches /model on explicit selection and consumes the token', async () => {
+ const screen = render(ChatFormPickersHarness);
+ await tick();
+
+ screen.component.type('/model is broken');
+ await tick();
+
+ const pickers = screen.component.getPickers();
+ const model = pickers.availableCommands.find((c) => c.name === 'model');
+ if (!model) throw new Error('model command missing');
+ pickers.handleCommandSelect(model);
+ await tick();
+
+ expect(screen.component.getValue()).toBe('');
+ expect(screen.component.getCalls()).toContain('openModelSelector');
+ expect(pickers.isCommandPickerOpen).toBe(false);
+ });
+
+ it('seeds the prompt picker search from the token args on selection', async () => {
+ const screen = render(ChatFormPickersHarness);
+ await tick();
+
+ screen.component.type('/prompt weather');
+ await tick();
+
+ const pickers = screen.component.getPickers();
+ expect(pickers.isPromptPickerOpen).toBe(false);
+
+ const prompt = pickers.availableCommands.find((c) => c.name === 'prompt');
+ if (!prompt) throw new Error('prompt command missing');
+ pickers.handleCommandSelect(prompt);
+ await tick();
+
+ expect(screen.component.getValue()).toBe('');
+ expect(pickers.isPromptPickerOpen).toBe(true);
+ expect(pickers.promptSearchQuery).toBe('weather');
+ });
+
+ it('normalizes a partial /cwd token on selection and keeps it in the buffer', async () => {
+ const screen = render(ChatFormPickersHarness);
+ await tick();
+
+ screen.component.type('/cw docs');
+ await tick();
+
+ const pickers = screen.component.getPickers();
+ expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
+
+ const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
+ if (!cwd) throw new Error('cwd command missing');
+ pickers.handleCommandSelect(cwd);
+ await tick();
+
+ expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
+ expect(pickers.workingDirectoryQuery).toBe('docs');
+ expect(screen.component.getValue()).toBe('/cwd docs');
+ });
+
+ it('syncs the /cwd token into the picker search while the picker is open', async () => {
+ const screen = render(ChatFormPickersHarness);
+ await tick();
+
+ const pickers = screen.component.getPickers();
+ const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
+ if (!cwd) throw new Error('cwd command missing');
+
+ screen.component.type('/cwd docs');
+ pickers.handleCommandSelect(cwd);
+ await tick();
+
+ screen.component.type('/cwd docs/sub');
+ await tick();
+
+ expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
+ expect(pickers.workingDirectoryQuery).toBe('docs/sub');
+ expect(pickers.isCommandPickerOpen).toBe(false);
+ });
+
+ it('abandons the /cwd picker when the token is edited away from /cwd', async () => {
+ const screen = render(ChatFormPickersHarness);
+ await tick();
+
+ const pickers = screen.component.getPickers();
+ const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
+ if (!cwd) throw new Error('cwd command missing');
+
+ screen.component.type('/cwd docs');
+ pickers.handleCommandSelect(cwd);
+ await tick();
+
+ screen.component.type('/cwdd docs');
+ await tick();
+
+ expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
+ });
+});
--- /dev/null
+<script lang="ts">
+ import {
+ useChatFormPickers,
+ type UseChatFormPickersReturn
+ } from '$lib/hooks/use-chat-form-pickers.svelte';
+
+ let value = $state('');
+ let caretOffset = $state(0);
+ const calls: string[] = [];
+
+ const pickers = useChatFormPickers({
+ getValue: () => value,
+ setValue: (v) => {
+ value = v;
+ calls.push(`setValue:${v}`);
+ },
+ getCaretOffset: () => caretOffset,
+ setCaretOffset: (o) => {
+ caretOffset = o;
+ },
+ focusInput: () => {},
+ getShowModelSelector: () => true,
+ hasPrompts: () => true,
+ hasBuiltinTools: () => true,
+ getCwd: () => null,
+ getServerHome: () => null,
+ openModelSelector: () => {
+ calls.push('openModelSelector');
+ },
+ getPickersRef: () => undefined
+ });
+
+ // Simulate the user typing: update the buffer and run the input flow.
+ export function type(text: string) {
+ value = text;
+ caretOffset = text.length;
+ pickers.handleInput();
+ }
+
+ export function getValue() {
+ return value;
+ }
+
+ export function getCalls() {
+ return calls;
+ }
+
+ export function getPickers(): UseChatFormPickersReturn {
+ return pickers;
+ }
+</script>
--- /dev/null
+// Guards the legacy render-key migration: `renderUserContentAsMarkdown`
+// and `renderThinkingAsMarkdown` (opt-INTO markdown) fold into the single
+// `renderContentAsRawText` setting, with any explicit raw-text preference
+// winning when the legacy keys disagree. Legacy keys are removed from the
+// persisted config so they do not stay orphaned in localStorage.
+
+import { beforeEach, describe, expect, it } from 'vitest';
+import { settingsStore, config } from '$lib/stores/settings.svelte';
+import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
+
+function seedConfig(stored: Record<string, unknown>) {
+ localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
+ settingsStore.initialize();
+}
+
+function persisted(): Record<string, unknown> {
+ return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
+}
+
+describe('renderContentAsRawText migration', () => {
+ beforeEach(() => {
+ localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
+ settingsStore.initialize();
+ });
+
+ it('maps renderUserContentAsMarkdown=false to raw text', () => {
+ seedConfig({ renderUserContentAsMarkdown: false });
+ expect(config().renderContentAsRawText).toBe(true);
+ });
+
+ it('maps renderUserContentAsMarkdown=true to markdown', () => {
+ seedConfig({ renderUserContentAsMarkdown: true });
+ expect(config().renderContentAsRawText).toBe(false);
+ });
+
+ it('maps renderThinkingAsMarkdown=false to raw text', () => {
+ seedConfig({ renderThinkingAsMarkdown: false });
+ expect(config().renderContentAsRawText).toBe(true);
+ });
+
+ it('lets any explicit raw-text preference win when the legacy keys disagree', () => {
+ seedConfig({ renderUserContentAsMarkdown: true, renderThinkingAsMarkdown: false });
+ expect(config().renderContentAsRawText).toBe(true);
+ });
+
+ it('honors the intermediate renderUserContentAsRawText key from the PR branch', () => {
+ seedConfig({ renderUserContentAsRawText: true });
+ expect(config().renderContentAsRawText).toBe(true);
+ });
+
+ it('keeps an already-migrated value and cleans up the legacy keys', () => {
+ seedConfig({ renderContentAsRawText: false, renderUserContentAsMarkdown: false });
+ expect(config().renderContentAsRawText).toBe(false);
+
+ const stored = persisted();
+ expect(stored.renderUserContentAsMarkdown).toBeUndefined();
+ expect(stored.renderThinkingAsMarkdown).toBeUndefined();
+ expect(stored.renderUserContentAsRawText).toBeUndefined();
+ });
+
+ it('defaults to markdown when no legacy key exists', () => {
+ seedConfig({});
+ expect(config().renderContentAsRawText).toBe(false);
+ });
+});
--- /dev/null
+import { describe, expect, it } from 'vitest';
+import { findCommandToken, takeCommandDismissSnapshot } from '$lib/utils';
+
+describe('findCommandToken', () => {
+ it('returns null when the value does not start with a slash', () => {
+ expect(findCommandToken('hello /prompt')).toBeNull();
+ expect(findCommandToken('')).toBeNull();
+ expect(findCommandToken('prompt')).toBeNull();
+ });
+
+ it('parses a bare slash', () => {
+ expect(findCommandToken('/')).toEqual({ name: '', args: '', end: 1 });
+ });
+
+ it('parses a command name with no args', () => {
+ expect(findCommandToken('/prompt')).toEqual({ name: 'prompt', args: '', end: 7 });
+ });
+
+ it('parses a command name followed by a space', () => {
+ expect(findCommandToken('/prompt ')).toEqual({ name: 'prompt', args: '', end: 8 });
+ });
+
+ it('parses args after the command name', () => {
+ expect(findCommandToken('/prompt rev')).toEqual({ name: 'prompt', args: 'rev', end: 11 });
+ });
+
+ it('parses multi-word args', () => {
+ expect(findCommandToken('/prompt review code ')).toEqual({
+ name: 'prompt',
+ args: ' review code ',
+ end: 22
+ });
+ });
+
+ it('treats the whole run as the name when there is no space', () => {
+ expect(findCommandToken('/promptx')).toEqual({ name: 'promptx', args: '', end: 8 });
+ });
+});
+
+describe('takeCommandDismissSnapshot', () => {
+ it('returns null when there is no command token', () => {
+ expect(takeCommandDismissSnapshot('hello')).toBeNull();
+ });
+
+ it('captures the name and args', () => {
+ expect(takeCommandDismissSnapshot('/prompt rev')).toEqual({
+ name: 'prompt',
+ args: 'rev'
+ });
+ });
+});