From 25fa2e4f776e779f136ddd2c909a0b70686f1944 Mon Sep 17 00:00:00 2001 From: Yishen Tu Date: Sat, 7 Mar 2026 22:18:43 +0800 Subject: [PATCH 1/3] feat: add slash commands with card gallery view and inline editor - Add slash command definitions, validation, resolution, and menu UI - Render slash commands as a 2-column card gallery in settings page - Default cards to view mode; editor opens only on explicit Edit click - Fix editor visibility bug where flex display overrode hidden attribute - Use data-slash-command-mode CSS selectors for view/edit toggling - Add userDisplayText metadata for resolved slash command display - Include comprehensive unit tests for slash commands and settings --- .../gemini/gemini/content-normalize.ts | 5 + .../features/gemini/gemini/content-render.ts | 7 + .../features/runtime/handlers/chat-send.ts | 33 ++- src/background/features/session/types.ts | 1 + src/chatpanel/app/bootstrap.ts | 41 ++- .../features/composer/slash-command-menu.ts | 218 ++++++++++++++++ src/chatpanel/template/composer-template.ts | 4 + src/chatpanel/template/styles.ts | 71 +++++- src/options/dom.ts | 8 + src/options/form-state.ts | 98 ++++++++ src/options/options.html | 78 ++++++ src/options/options.ts | 164 +++++++++++- src/shared/settings.ts | 7 + src/shared/slash-commands.ts | 170 +++++++++++++ src/styles/tailwind.css | 105 ++++++++ .../app/runtime-chat-storage.test.ts | 54 ++++ .../features/session/sessions.test.ts | 9 +- tests/unit/chatpanel/app/regen-flow.test.ts | 40 ++- .../composer/slash-command-menu.test.ts | 238 ++++++++++++++++++ .../unit/chatpanel/template/template.test.ts | 23 ++ tests/unit/options/dom.test.ts | 6 + tests/unit/options/form-state.test.ts | 92 +++++++ tests/unit/options/markup.test.ts | 34 +++ tests/unit/options/options.test.ts | 185 +++++++++++++- tests/unit/shared/settings.test.ts | 12 + tests/unit/shared/slash-commands.test.ts | 84 +++++++ 26 files changed, 1765 insertions(+), 22 deletions(-) create mode 100644 src/chatpanel/features/composer/slash-command-menu.ts create mode 100644 src/shared/slash-commands.ts create mode 100644 tests/unit/chatpanel/features/composer/slash-command-menu.test.ts create mode 100644 tests/unit/options/markup.test.ts create mode 100644 tests/unit/shared/slash-commands.test.ts diff --git a/src/background/features/gemini/gemini/content-normalize.ts b/src/background/features/gemini/gemini/content-normalize.ts index 2c453e6..6dff200 100644 --- a/src/background/features/gemini/gemini/content-normalize.ts +++ b/src/background/features/gemini/gemini/content-normalize.ts @@ -209,6 +209,7 @@ function normalizeContentMetadata(value: unknown): GeminiContent['metadata'] | u const interactionId = readStringField(value, 'interactionId', 'interaction_id'); const sourceModel = readStringField(value, 'sourceModel', 'source_model'); const createdAt = readStringField(value, 'createdAt', 'created_at'); + const userDisplayText = readStringField(value, 'userDisplayText', 'user_display_text'); const attachmentPreviewByFileUri = normalizeAttachmentPreviewByFileUri( readPartRecord(value, 'attachmentPreviewByFileUri', 'attachment_preview_by_file_uri'), ); @@ -224,6 +225,7 @@ function normalizeContentMetadata(value: unknown): GeminiContent['metadata'] | u !interactionId && !sourceModel && !createdAt && + !userDisplayText && !attachmentPreviewByFileUri && !attachmentPreviewTextByFileUri && !groundingSources @@ -244,6 +246,9 @@ function normalizeContentMetadata(value: unknown): GeminiContent['metadata'] | u if (createdAt) { metadata.createdAt = createdAt; } + if (userDisplayText) { + metadata.userDisplayText = userDisplayText; + } if (attachmentPreviewByFileUri) { metadata.attachmentPreviewByFileUri = attachmentPreviewByFileUri; } diff --git a/src/background/features/gemini/gemini/content-render.ts b/src/background/features/gemini/gemini/content-render.ts index 2fc7654..541cc40 100644 --- a/src/background/features/gemini/gemini/content-render.ts +++ b/src/background/features/gemini/gemini/content-render.ts @@ -9,6 +9,13 @@ import { } from './common'; export function renderContentForChat(content: GeminiContent): string { + if (content.role === 'user') { + const displayText = content.metadata?.userDisplayText?.trim(); + if (displayText) { + return displayText; + } + } + const blocks: string[] = []; for (const part of content.parts) { diff --git a/src/background/features/runtime/handlers/chat-send.ts b/src/background/features/runtime/handlers/chat-send.ts index 1449e6d..a389630 100644 --- a/src/background/features/runtime/handlers/chat-send.ts +++ b/src/background/features/runtime/handlers/chat-send.ts @@ -1,4 +1,5 @@ import type { FileDataAttachmentPayload } from '../../../../shared/runtime'; +import { resolveSlashCommandText } from '../../../../shared/slash-commands'; import { isInvalidPreviousInteractionIdError } from '../../gemini/gemini'; import { appendContentsToBranch, @@ -32,17 +33,20 @@ export async function handleSendMessage( attachments: FileDataAttachmentPayload[] | undefined, dependencies: RuntimeDependencies, ): Promise { - const normalizedText = text.trim(); + const normalizedDisplayText = text.trim(); const normalizedAttachments = normalizeFileDataAttachments(attachments); - if (!normalizedText && normalizedAttachments.length === 0) { - throw new Error('Cannot send an empty message.'); - } const settings = await dependencies.readGeminiSettings(); if (!settings.apiKey) { throw new Error('Gemini API key is missing. Add it in Speakeasy Settings.'); } + const resolvedUserText = resolveSlashCommandText(normalizedDisplayText, settings.slashCommands); + const normalizedText = resolvedUserText.resolvedText.trim(); + if (!normalizedText && normalizedAttachments.length === 0) { + throw new Error('Cannot send an empty message.'); + } + if (model) { settings.model = model; } @@ -71,15 +75,26 @@ export async function handleSendMessage( role: 'user', parts: userParts, }; + const shouldPersistDisplayText = + !!resolvedUserText.command && normalizedDisplayText !== normalizedText; + const userMetadata: NonNullable = {}; + if (shouldPersistDisplayText) { + userMetadata.userDisplayText = normalizedDisplayText; + } const attachmentPreviewByFileUri = buildAttachmentPreviewByFileUri(normalizedAttachments); const attachmentPreviewTextByFileUri = buildAttachmentPreviewTextByFileUri(normalizedAttachments); const hasImagePreviews = Object.keys(attachmentPreviewByFileUri).length > 0; const hasTextPreviews = Object.keys(attachmentPreviewTextByFileUri).length > 0; if (hasImagePreviews || hasTextPreviews) { - userContent.metadata = { - ...(hasImagePreviews ? { attachmentPreviewByFileUri } : {}), - ...(hasTextPreviews ? { attachmentPreviewTextByFileUri } : {}), - }; + if (hasImagePreviews) { + userMetadata.attachmentPreviewByFileUri = attachmentPreviewByFileUri; + } + if (hasTextPreviews) { + userMetadata.attachmentPreviewTextByFileUri = attachmentPreviewTextByFileUri; + } + } + if (Object.keys(userMetadata).length > 0) { + userContent.metadata = userMetadata; } const branchStartNodeId = workingSession.branchTree?.activeLeafNodeId; if (!branchStartNodeId) { @@ -137,7 +152,7 @@ export async function handleSendMessage( const pendingTitleGeneration: PendingSessionTitleGeneration = { chatId: workingSession.id, apiKey: settings.apiKey, - firstUserQuery: normalizedText, + firstUserQuery: normalizedDisplayText || normalizedText, }; if (normalizedAttachments.length > 0) { pendingTitleGeneration.attachments = normalizedAttachments; diff --git a/src/background/features/session/types.ts b/src/background/features/session/types.ts index 45d907e..faa02c7 100644 --- a/src/background/features/session/types.ts +++ b/src/background/features/session/types.ts @@ -146,6 +146,7 @@ export interface GeminiContentMetadata { interactionId?: string; sourceModel?: string; createdAt?: string; + userDisplayText?: string; attachmentPreviewByFileUri?: Record; attachmentPreviewTextByFileUri?: Record; groundingSources?: GroundingSource[]; diff --git a/src/chatpanel/app/bootstrap.ts b/src/chatpanel/app/bootstrap.ts index 2462a16..13097f5 100644 --- a/src/chatpanel/app/bootstrap.ts +++ b/src/chatpanel/app/bootstrap.ts @@ -40,6 +40,7 @@ import { } from '../features/attachments/page-text-extraction'; import { readAttachedTextPreview } from '../features/attachments/text-preview'; import { createInputToolbar } from '../features/composer/input-toolbar'; +import { createSlashCommandMenuController } from '../features/composer/slash-command-menu'; import { canSubmitMessage, createConversationFlowController, @@ -104,6 +105,18 @@ export function mountChatPanel(): void { const input = queryRequiredElement(shadowRoot, '#speakeasy-input'); const fileInput = queryRequiredElement(shadowRoot, '#speakeasy-file-input'); const filePreviews = queryRequiredElement(shadowRoot, '#speakeasy-file-previews'); + const slashCommandMenu = queryRequiredElement( + shadowRoot, + '#speakeasy-slash-command-menu', + ); + const slashCommandList = queryRequiredElement( + shadowRoot, + '#speakeasy-slash-command-list', + ); + const slashCommandEmpty = queryRequiredElement( + shadowRoot, + '#speakeasy-slash-command-empty', + ); const tabMentionMenu = queryRequiredElement( shadowRoot, '#speakeasy-tab-mention-menu', @@ -196,12 +209,15 @@ export function mountChatPanel(): void { input.addEventListener('input', () => { resizeComposerInput(); + slashCommandController.onInputOrCaretChange(); tabMentionController.onInputOrCaretChange(); }); input.addEventListener('click', () => { + slashCommandController.onInputOrCaretChange(); tabMentionController.onInputOrCaretChange(); }); input.addEventListener('keyup', () => { + slashCommandController.onInputOrCaretChange(); tabMentionController.onInputOrCaretChange(); }); const stopInputKeyboardPropagation = (event: KeyboardEvent) => { @@ -335,6 +351,14 @@ export function mountChatPanel(): void { isBusy: () => isBusy || isCapturingFullPageScreenshot || isExtractingPageText || isProcessingMentionAction, }); + const slashCommandController = createSlashCommandMenuController({ + input, + menu: slashCommandMenu, + list: slashCommandList, + emptyState: slashCommandEmpty, + isBusy: () => + isBusy || isCapturingFullPageScreenshot || isExtractingPageText || isProcessingMentionAction, + }); async function ensureChatTabContext(): Promise { if (hasResolvedChatTabContext) { @@ -504,6 +528,10 @@ export function mountChatPanel(): void { }); input.addEventListener('keydown', (event) => { + if (event.defaultPrevented) { + return; + } + if (event.key === 'Enter' && !event.shiftKey) { if (isInputComposing || event.isComposing || event.keyCode === 229) { return; @@ -525,6 +553,7 @@ export function mountChatPanel(): void { input.addEventListener('blur', () => { isInputComposing = false; + slashCommandController.close(); }); shell.addEventListener('dragenter', (event) => { @@ -625,7 +654,10 @@ export function mountChatPanel(): void { } const keyboardEvent = event as KeyboardEvent; - if (tabMentionController.onKeyDown(keyboardEvent)) { + if ( + tabMentionController.onKeyDown(keyboardEvent) || + slashCommandController.onKeyDown(keyboardEvent) + ) { keyboardEvent.stopPropagation(); } }, @@ -660,6 +692,7 @@ export function mountChatPanel(): void { onClose: () => { dragEnterDepth = 0; form.classList.remove('drop-active'); + slashCommandController.close(); tabMentionController.close(); closeImagePreview(); closeTextPreview(); @@ -830,6 +863,7 @@ export function mountChatPanel(): void { isBusy = nextBusy; syncComposerDisabledState(); if (nextBusy) { + slashCommandController.close(); tabMentionController.close(); } syncToolbarButtonState(); @@ -859,10 +893,14 @@ export function mountChatPanel(): void { function syncComposerDisabledState(): void { input.disabled = isBusy || isProcessingMentionAction; + if (input.disabled) { + slashCommandController.close(); + } } function openImagePreview(imageUrl: string, imageLabel: string): void { closeTextPreview(); + slashCommandController.close(); imagePreviewElement.src = imageUrl; imagePreviewElement.alt = imageLabel || 'Image preview'; tabMentionController.close(); @@ -884,6 +922,7 @@ export function mountChatPanel(): void { function openTextPreview(title: string, text: string): void { closeImagePreview(); + slashCommandController.close(); textPreviewTitle.textContent = title.trim() || 'Markdown preview'; textPreviewContent.textContent = text; tabMentionController.close(); diff --git a/src/chatpanel/features/composer/slash-command-menu.ts b/src/chatpanel/features/composer/slash-command-menu.ts new file mode 100644 index 0000000..ff98c2f --- /dev/null +++ b/src/chatpanel/features/composer/slash-command-menu.ts @@ -0,0 +1,218 @@ +import { GEMINI_SETTINGS_STORAGE_KEY, normalizeGeminiSettings } from '../../../shared/settings'; +import { type SlashCommandDefinition, filterSlashCommands } from '../../../shared/slash-commands'; + +export interface SlashCommandMenuController { + onInputOrCaretChange(): void; + onKeyDown(event: KeyboardEvent): boolean; + close(): void; + dispose(): void; +} + +export interface CreateSlashCommandMenuControllerOptions { + input: HTMLTextAreaElement; + menu: HTMLElement; + list: HTMLElement; + emptyState: HTMLElement; + isBusy: () => boolean; +} + +export function createSlashCommandMenuController( + options: CreateSlashCommandMenuControllerOptions, +): SlashCommandMenuController { + let slashCommands = normalizeGeminiSettings(undefined).slashCommands; + let visibleCommands: SlashCommandDefinition[] = []; + let selectedIndex = 0; + let activeQuery: string | null = null; + + function applySettings(settingsValue: unknown): void { + slashCommands = normalizeGeminiSettings(settingsValue).slashCommands; + onInputOrCaretChange(); + } + + function close(): void { + visibleCommands = []; + selectedIndex = 0; + activeQuery = null; + options.menu.hidden = true; + options.emptyState.hidden = true; + options.list.replaceChildren(); + } + + function render(): void { + options.list.replaceChildren( + ...visibleCommands.map((command, index) => + createCommandButton(command, index === selectedIndex), + ), + ); + options.menu.hidden = false; + options.emptyState.hidden = visibleCommands.length > 0; + } + + function updateSelection(nextIndex: number): void { + if (visibleCommands.length === 0) { + selectedIndex = 0; + render(); + return; + } + + selectedIndex = Math.max(0, Math.min(nextIndex, visibleCommands.length - 1)); + render(); + const activeItem = options.list.querySelector('[aria-selected="true"]'); + activeItem?.scrollIntoView({ block: 'nearest' }); + } + + function applySelection(command: SlashCommandDefinition): void { + const nextValue = `/${command.name} `; + options.input.value = nextValue; + options.input.focus(); + options.input.setSelectionRange(nextValue.length, nextValue.length); + close(); + options.input.dispatchEvent(new Event('input', { bubbles: true })); + } + + function onInputOrCaretChange(): void { + const query = readSlashQuery(); + if (query === null) { + close(); + return; + } + + const previousSelectionName = visibleCommands[selectedIndex]?.name; + const nextVisibleCommands = filterSlashCommands(slashCommands, query); + visibleCommands = nextVisibleCommands; + + if (query === activeQuery && previousSelectionName) { + const preservedIndex = visibleCommands.findIndex( + (command) => command.name === previousSelectionName, + ); + selectedIndex = preservedIndex >= 0 ? preservedIndex : 0; + } else { + selectedIndex = 0; + } + + activeQuery = query; + render(); + } + + function onKeyDown(event: KeyboardEvent): boolean { + if (options.menu.hidden) { + return false; + } + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + updateSelection(selectedIndex + 1); + return true; + case 'ArrowUp': + event.preventDefault(); + updateSelection(selectedIndex - 1); + return true; + case 'Enter': + case 'Tab': { + const selectedCommand = visibleCommands[selectedIndex]; + if (!selectedCommand) { + return false; + } + event.preventDefault(); + applySelection(selectedCommand); + return true; + } + case 'Escape': + event.preventDefault(); + close(); + return true; + default: + return false; + } + } + + function createCommandButton( + command: SlashCommandDefinition, + selected: boolean, + ): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'slash-command-item'; + button.dataset.commandName = command.name; + button.setAttribute('role', 'option'); + button.setAttribute('aria-selected', selected ? 'true' : 'false'); + + const name = document.createElement('span'); + name.className = 'slash-command-item-name'; + name.dataset.slashCommandName = 'true'; + name.textContent = `/${command.name}`; + + const prompt = document.createElement('span'); + prompt.className = 'slash-command-item-prompt'; + prompt.textContent = command.prompt; + + button.append(name, prompt); + return button; + } + + function readSlashQuery(): string | null { + if (options.isBusy()) { + return null; + } + + const text = options.input.value; + if (!text.startsWith('/')) { + return null; + } + + const selectionStart = options.input.selectionStart ?? text.length; + const selectionEnd = options.input.selectionEnd ?? selectionStart; + if (selectionStart !== selectionEnd) { + return null; + } + + const remainder = text.slice(1); + const whitespaceIndex = remainder.search(/\s/); + const commandEnd = whitespaceIndex === -1 ? text.length : whitespaceIndex + 1; + if (selectionStart > commandEnd) { + return null; + } + + return text.slice(1, commandEnd).trim(); + } + + const onListClick = (event: Event): void => { + const item = (event.target as Element).closest('.slash-command-item'); + if (!item) { + return; + } + + const selectedCommand = visibleCommands.find( + (command) => command.name === item.dataset.commandName, + ); + if (selectedCommand) { + applySelection(selectedCommand); + } + }; + + const onStorageChanged = (changes: Record): void => { + const settingsChange = changes[GEMINI_SETTINGS_STORAGE_KEY]; + if (!settingsChange) { + return; + } + + applySettings(settingsChange.newValue); + }; + + options.list.addEventListener('click', onListClick); + void chrome.storage.local.get(GEMINI_SETTINGS_STORAGE_KEY).then((stored) => { + applySettings(stored[GEMINI_SETTINGS_STORAGE_KEY]); + }); + chrome.storage.onChanged.addListener(onStorageChanged); + + return { + onInputOrCaretChange, + onKeyDown, + close, + dispose: () => { + options.list.removeEventListener('click', onListClick); + chrome.storage.onChanged.removeListener(onStorageChanged); + }, + }; +} diff --git a/src/chatpanel/template/composer-template.ts b/src/chatpanel/template/composer-template.ts index e70b22a..ef8d416 100644 --- a/src/chatpanel/template/composer-template.ts +++ b/src/chatpanel/template/composer-template.ts @@ -47,6 +47,10 @@ export function getComposerTemplate(): string { hidden />
+