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:
@@ -0,0 +1,64 @@
|
||||
// Guards the @-mention picker's file_glob_search gate: when the server
|
||||
// does not expose the tool (started without --tools) or the user disabled
|
||||
// it, the picker still opens but explains why instead of firing searches
|
||||
// that would only fail with "Search failed".
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ChatFormMentionPicker from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import { DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import type { OpenAIToolDefinition } from '$lib/types';
|
||||
|
||||
const FILE_SEARCH_DEF: OpenAIToolDefinition = {
|
||||
type: 'function',
|
||||
function: { name: BuiltInTool.FILE_GLOB_SEARCH, description: '', parameters: {} }
|
||||
};
|
||||
|
||||
const FILE_SEARCH_KEY = `builtin:${BuiltInTool.FILE_GLOB_SEARCH}`;
|
||||
|
||||
// The store keeps its builtin tool list private; tests inject it through
|
||||
// the reactive field so the derived gates recompute.
|
||||
function setBuiltinTools(defs: OpenAIToolDefinition[]) {
|
||||
(toolsStore as unknown as { _builtinTools: OpenAIToolDefinition[] })._builtinTools = defs;
|
||||
}
|
||||
|
||||
function renderPicker() {
|
||||
return render(ChatFormMentionPicker, {
|
||||
isOpen: true,
|
||||
query: 'main',
|
||||
onClose: () => {},
|
||||
onSelect: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setBuiltinTools([]);
|
||||
toolsStore.setToolEnabled(FILE_SEARCH_KEY, true);
|
||||
localStorage.removeItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('ChatFormMentionPicker file_glob_search gate', () => {
|
||||
it('explains that file search is unavailable when the server has no tools', async () => {
|
||||
setBuiltinTools([]);
|
||||
renderPicker();
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
'File search is unavailable on this server (started without --tools)'
|
||||
);
|
||||
});
|
||||
|
||||
it('explains that file search must be enabled when the user disabled it', async () => {
|
||||
setBuiltinTools([FILE_SEARCH_DEF]);
|
||||
toolsStore.setToolEnabled(FILE_SEARCH_KEY, false);
|
||||
renderPicker();
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// Guards the slash-command dispatch contract: commands dispatch only on
|
||||
// explicit selection (Enter/click in the picker), never mid-typing.
|
||||
// Typing `/model is broken` is prose until the command is picked - the
|
||||
// buffer must survive; only an actual selection consumes the token.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ChatFormPickersHarness from './components/ChatFormPickersHarness.svelte';
|
||||
|
||||
describe('slash command dispatch', () => {
|
||||
it('does not dispatch or clear the buffer when a space follows the name', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/model is broken');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
expect(screen.component.getValue()).toBe('/model is broken');
|
||||
expect(screen.component.getCalls()).not.toContain('openModelSelector');
|
||||
expect(screen.component.getCalls().some((c) => c.startsWith('setValue:'))).toBe(false);
|
||||
expect(pickers.isCommandPickerOpen).toBe(true);
|
||||
expect(pickers.commandQuery).toBe('model');
|
||||
});
|
||||
|
||||
it('dispatches /model on explicit selection and consumes the token', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/model is broken');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const model = pickers.availableCommands.find((c) => c.name === 'model');
|
||||
if (!model) throw new Error('model command missing');
|
||||
pickers.handleCommandSelect(model);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('');
|
||||
expect(screen.component.getCalls()).toContain('openModelSelector');
|
||||
expect(pickers.isCommandPickerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('seeds the prompt picker search from the token args on selection', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/prompt weather');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
expect(pickers.isPromptPickerOpen).toBe(false);
|
||||
|
||||
const prompt = pickers.availableCommands.find((c) => c.name === 'prompt');
|
||||
if (!prompt) throw new Error('prompt command missing');
|
||||
pickers.handleCommandSelect(prompt);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('');
|
||||
expect(pickers.isPromptPickerOpen).toBe(true);
|
||||
expect(pickers.promptSearchQuery).toBe('weather');
|
||||
});
|
||||
|
||||
it('normalizes a partial /cwd token on selection and keeps it in the buffer', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cw docs');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
|
||||
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
|
||||
expect(pickers.workingDirectoryQuery).toBe('docs');
|
||||
expect(screen.component.getValue()).toBe('/cwd docs');
|
||||
});
|
||||
|
||||
it('syncs the /cwd token into the picker search while the picker is open', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
|
||||
screen.component.type('/cwd docs');
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cwd docs/sub');
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
|
||||
expect(pickers.workingDirectoryQuery).toBe('docs/sub');
|
||||
expect(pickers.isCommandPickerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('abandons the /cwd picker when the token is edited away from /cwd', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
|
||||
screen.component.type('/cwd docs');
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cwdd docs');
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
useChatFormPickers,
|
||||
type UseChatFormPickersReturn
|
||||
} from '$lib/hooks/use-chat-form-pickers.svelte';
|
||||
|
||||
let value = $state('');
|
||||
let caretOffset = $state(0);
|
||||
const calls: string[] = [];
|
||||
|
||||
const pickers = useChatFormPickers({
|
||||
getValue: () => value,
|
||||
setValue: (v) => {
|
||||
value = v;
|
||||
calls.push(`setValue:${v}`);
|
||||
},
|
||||
getCaretOffset: () => caretOffset,
|
||||
setCaretOffset: (o) => {
|
||||
caretOffset = o;
|
||||
},
|
||||
focusInput: () => {},
|
||||
getShowModelSelector: () => true,
|
||||
hasPrompts: () => true,
|
||||
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>
|
||||
@@ -0,0 +1,65 @@
|
||||
// Guards the legacy render-key migration: `renderUserContentAsMarkdown`
|
||||
// and `renderThinkingAsMarkdown` (opt-INTO markdown) fold into the single
|
||||
// `renderContentAsRawText` setting, with any explicit raw-text preference
|
||||
// winning when the legacy keys disagree. Legacy keys are removed from the
|
||||
// persisted config so they do not stay orphaned in localStorage.
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { settingsStore, config } from '$lib/stores/settings.svelte';
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
|
||||
|
||||
function seedConfig(stored: Record<string, unknown>) {
|
||||
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
|
||||
settingsStore.initialize();
|
||||
}
|
||||
|
||||
function persisted(): Record<string, unknown> {
|
||||
return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
|
||||
}
|
||||
|
||||
describe('renderContentAsRawText migration', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
settingsStore.initialize();
|
||||
});
|
||||
|
||||
it('maps renderUserContentAsMarkdown=false to raw text', () => {
|
||||
seedConfig({ renderUserContentAsMarkdown: false });
|
||||
expect(config().renderContentAsRawText).toBe(true);
|
||||
});
|
||||
|
||||
it('maps renderUserContentAsMarkdown=true to markdown', () => {
|
||||
seedConfig({ renderUserContentAsMarkdown: true });
|
||||
expect(config().renderContentAsRawText).toBe(false);
|
||||
});
|
||||
|
||||
it('maps renderThinkingAsMarkdown=false to raw text', () => {
|
||||
seedConfig({ renderThinkingAsMarkdown: false });
|
||||
expect(config().renderContentAsRawText).toBe(true);
|
||||
});
|
||||
|
||||
it('lets any explicit raw-text preference win when the legacy keys disagree', () => {
|
||||
seedConfig({ renderUserContentAsMarkdown: true, renderThinkingAsMarkdown: false });
|
||||
expect(config().renderContentAsRawText).toBe(true);
|
||||
});
|
||||
|
||||
it('honors the intermediate renderUserContentAsRawText key from the PR branch', () => {
|
||||
seedConfig({ renderUserContentAsRawText: true });
|
||||
expect(config().renderContentAsRawText).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an already-migrated value and cleans up the legacy keys', () => {
|
||||
seedConfig({ renderContentAsRawText: false, renderUserContentAsMarkdown: false });
|
||||
expect(config().renderContentAsRawText).toBe(false);
|
||||
|
||||
const stored = persisted();
|
||||
expect(stored.renderUserContentAsMarkdown).toBeUndefined();
|
||||
expect(stored.renderThinkingAsMarkdown).toBeUndefined();
|
||||
expect(stored.renderUserContentAsRawText).toBeUndefined();
|
||||
});
|
||||
|
||||
it('defaults to markdown when no legacy key exists', () => {
|
||||
seedConfig({});
|
||||
expect(config().renderContentAsRawText).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user