diff --git a/src/server/db/settings.ts b/src/server/db/settings.ts index e36bbbb6..6721409c 100644 --- a/src/server/db/settings.ts +++ b/src/server/db/settings.ts @@ -31,6 +31,7 @@ export const SETTINGS_KEYS = { DISPLAY_COLLAPSE_PROVIDERS_BY_DEFAULT: 'display.collapseProvidersByDefault', DISPLAY_COLLAPSE_FAVORITES_BY_DEFAULT: 'display.collapseFavoritesByDefault', DISPLAY_MODEL_FAVORITES: 'display.modelFavorites', + DISPLAY_FULLSCREEN_SLASH_COMMAND: 'display.fullscreenSlashCommand', LLM_DYNAMIC_SYSTEM_PROMPT: 'llm.dynamicSystemPrompt', CACHE_WARMING: 'cache.warming', KEYBINDINGS: 'keybindings', @@ -76,6 +77,7 @@ export const SETTINGS_DEFAULTS: Record = { [SETTINGS_KEYS.DISPLAY_COLLAPSE_PROVIDERS_BY_DEFAULT]: 'false', [SETTINGS_KEYS.DISPLAY_COLLAPSE_FAVORITES_BY_DEFAULT]: 'false', [SETTINGS_KEYS.DISPLAY_MODEL_FAVORITES]: '[]', + [SETTINGS_KEYS.DISPLAY_FULLSCREEN_SLASH_COMMAND]: 'false', [SETTINGS_KEYS.LLM_DYNAMIC_SYSTEM_PROMPT]: 'false', [SETTINGS_KEYS.CACHE_WARMING]: 'false', [SETTINGS_KEYS.RETRY_PATTERNS]: JSON.stringify({ patterns: [], maxRetriesPerTurn: 10 }), diff --git a/src/server/llm/proxy.test.ts b/src/server/llm/proxy.test.ts index 15a1b0db..563af5ed 100644 --- a/src/server/llm/proxy.test.ts +++ b/src/server/llm/proxy.test.ts @@ -1,4 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createServer, type Server } from 'http' +import type { AddressInfo } from 'net' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSetting } = vi.hoisted(() => ({ mockGetSetting: vi.fn(), @@ -39,6 +41,27 @@ vi.mock('undici', () => { import { __resetProxyCache } from './proxy.js' describe('global fetch override', () => { + let localServer: Server + let localUrl: string + + beforeAll(async () => { + localServer = createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/plain' }) + res.end('local-native-response') + }) + await new Promise((resolve) => { + localServer.listen(0, '127.0.0.1', () => resolve()) + }) + const addr = localServer.address() as AddressInfo + localUrl = `http://127.0.0.1:${addr.port}` + }) + + afterAll(async () => { + await new Promise((resolve) => { + localServer.close(() => resolve()) + }) + }) + beforeEach(() => { vi.clearAllMocks() mockProxyAgentInstances.length = 0 @@ -48,7 +71,7 @@ describe('global fetch override', () => { it('calls native fetch when no proxy is configured', async () => { mockGetSetting.mockReturnValue(null) - const result = await fetch('http://example.com') + const result = await fetch(localUrl) expect(result).toBeInstanceOf(Response) expect(mockUndiciFetch).not.toHaveBeenCalled() @@ -57,7 +80,7 @@ describe('global fetch override', () => { it('calls native fetch when proxy URL is empty string', async () => { mockGetSetting.mockReturnValue('') - const result = await fetch('http://example.com') + const result = await fetch(localUrl) expect(result).toBeInstanceOf(Response) expect(mockUndiciFetch).not.toHaveBeenCalled() @@ -138,7 +161,7 @@ describe('global fetch override', () => { expect(oldAgent).toBeDefined() mockGetSetting.mockReturnValue(null) - await fetch('http://example.com') + await fetch(localUrl) expect(oldAgent!.destroy).toHaveBeenCalledTimes(1) }) @@ -150,7 +173,7 @@ describe('global fetch override', () => { expect(mockUndiciFetch).toHaveBeenCalledTimes(1) mockGetSetting.mockReturnValue(null) - const result = await fetch('http://example.com') + const result = await fetch(localUrl) expect(result).toBeInstanceOf(Response) expect(mockUndiciFetch).toHaveBeenCalledTimes(1) diff --git a/src/server/routes/skills.test.ts b/src/server/routes/skills.test.ts index e4488a5e..e1c7b0ef 100644 --- a/src/server/routes/skills.test.ts +++ b/src/server/routes/skills.test.ts @@ -60,13 +60,20 @@ describe('skill library routes', () => { const response = await fetch(`${baseUrl}/api/skills`) const body = (await response.json()) as { selectedDirectory: { configuredPath: string; resolvedPath: string } | null - items: Array<{ id: string; source: string; readOnly: boolean }> + items: Array<{ id: string; source: string; readOnly: boolean; estimatedTokens?: number }> defaults: unknown[] userItems: unknown[] projectItems: unknown[] } expect(body.selectedDirectory?.configuredPath).toBe(library) - expect(body.items).toContainEqual(expect.objectContaining({ id: 'portable', source: 'selected', readOnly: false })) + expect(body.items).toContainEqual( + expect.objectContaining({ + id: 'portable', + source: 'selected', + readOnly: false, + estimatedTokens: expect.any(Number), + }), + ) expect(body).toMatchObject({ defaults: expect.any(Array), userItems: [], projectItems: [] }) }) diff --git a/src/server/routes/skills.ts b/src/server/routes/skills.ts index 2cdbcc3f..c9517840 100644 --- a/src/server/routes/skills.ts +++ b/src/server/routes/skills.ts @@ -66,10 +66,15 @@ async function resolveLibrary(path: string): Promise<{ configuredPath: string; r return { configuredPath: path, resolvedPath: await realpath(absolute) } } +export function estimateSkillTokens(prompt: string): number { + return Math.ceil(prompt.length / 4) +} + function mapToResponse(skill: SkillDefinition) { const source = skill.source ?? 'global-openfox' return { ...skill.metadata, + estimatedTokens: estimateSkillTokens(skill.prompt), enabled: isSkillEnabled(skill.metadata.id), source, path: skill.entrypoint ?? null, diff --git a/src/server/skills/registry.test.ts b/src/server/skills/registry.test.ts index a3ba9108..9a7da7f9 100644 --- a/src/server/skills/registry.test.ts +++ b/src/server/skills/registry.test.ts @@ -188,6 +188,28 @@ describe('loadUserSkills', () => { expect(skills[0]).toMatchObject({ prompt: 'Portable prompt.', legacy: false }) }) + it('loads portable skills recursively from subdirectories and assigns group', async () => { + const categoryDir = join(tempDir, 'skills', 'tools') + await mkdir(categoryDir, { recursive: true }) + await createPortableInRoot(categoryDir, 'nested-tool', 'Tool prompt.') + + const subCategoryDir = join(tempDir, 'skills', 'dev', 'frontend') + await mkdir(subCategoryDir, { recursive: true }) + await createPortableInRoot(subCategoryDir, 'react-helper', 'React helper prompt.') + + const skills = await loadUserSkills(tempDir) + + const nested = skills.find((s) => s.metadata.id === 'nested-tool') + expect(nested).toBeDefined() + expect(nested?.metadata.group).toBe('tools') + expect(nested?.prompt).toBe('Tool prompt.') + + const deepNested = skills.find((s) => s.metadata.id === 'react-helper') + expect(deepNested).toBeDefined() + expect(deepNested?.metadata.group).toBe('dev/frontend') + expect(deepNested?.prompt).toBe('React helper prompt.') + }) + it('should skip files without an id', async () => { const skillsDir = join(tempDir, 'skills') await mkdir(skillsDir, { recursive: true }) diff --git a/src/server/skills/registry.ts b/src/server/skills/registry.ts index b90e8f30..28bbd523 100644 --- a/src/server/skills/registry.ts +++ b/src/server/skills/registry.ts @@ -54,10 +54,15 @@ function portableVersion(data: Record): string { return data['version'] === undefined ? '' : String(data['version']) } -async function loadPortableSkills(dir: string, source: SkillSource): Promise { +async function loadPortableSkills( + dir: string, + source: SkillSource, + currentDir: string = dir, + group?: string, +): Promise { let entries try { - entries = await readdir(dir, { withFileTypes: true }) + entries = await readdir(currentDir, { withFileTypes: true }) } catch { return [] } @@ -65,42 +70,53 @@ async function loadPortableSkills(dir: string, source: SkillSource): Promise - const id = typeof data['name'] === 'string' ? data['name'].trim() : '' - const description = typeof data['description'] === 'string' ? data['description'].trim() : '' - const prompt = parsed.content.trim() - if (!id || !description || !prompt) continue - const resolvedDirectory = await realpath(packageDir) - const warnings: string[] = [] - if (id.length > 64 || !PORTABLE_NAME_REGEX.test(id)) { - warnings.push('Skill name must use 1-64 lowercase letters, numbers, and single hyphens') - } - if (id !== entry.name) { - warnings.push(`Skill name "${id}" does not match package directory "${entry.name}"`) + if (await pathExists(entrypoint)) { + const content = await readFile(entrypoint, 'utf-8') + const parsed = matter(content) + const data = parsed.data as Record + const id = typeof data['name'] === 'string' ? data['name'].trim() : '' + const description = typeof data['description'] === 'string' ? data['description'].trim() : '' + const prompt = parsed.content.trim() + if (id && description && prompt) { + const resolvedDirectory = await realpath(packageDir) + const warnings: string[] = [] + if (id.length > 64 || !PORTABLE_NAME_REGEX.test(id)) { + warnings.push('Skill name must use 1-64 lowercase letters, numbers, and single hyphens') + } + if (id !== entry.name) { + warnings.push(`Skill name "${id}" does not match package directory "${entry.name}"`) + } + skills.push({ + metadata: { + id, + name: portableDisplayName(data, id), + description, + version: portableVersion(data), + ...(group ? { group } : {}), + }, + prompt, + rawMetadata: data, + entrypoint, + directory: resolvedDirectory, + source, + legacy: false, + warnings, + }) + // If this directory is a skill package, we don't recurse inside its internal subdirectories (e.g. assets, scripts) + continue + } } - skills.push({ - metadata: { - id, - name: portableDisplayName(data, id), - description, - version: portableVersion(data), - }, - prompt, - rawMetadata: data, - entrypoint, - directory: resolvedDirectory, - source, - legacy: false, - warnings, - }) } catch { // Invalid or unreadable packages do not block discovery. } + + // Not a skill package directly — recurse into subdirectory + const subGroup = group ? `${group}/${entry.name}` : entry.name + const nestedSkills = await loadPortableSkills(dir, source, packageDir, subGroup) + skills.push(...nestedSkills) } return skills } diff --git a/src/server/skills/types.ts b/src/server/skills/types.ts index 2dce2046..b809f4ef 100644 --- a/src/server/skills/types.ts +++ b/src/server/skills/types.ts @@ -7,6 +7,8 @@ export interface SkillMetadata { name: string description: string version: string + group?: string + estimatedTokens?: number [key: string]: unknown } diff --git a/web/src/components/plan/ChatInput.tsx b/web/src/components/plan/ChatInput.tsx index a0bb83eb..20846ede 100644 --- a/web/src/components/plan/ChatInput.tsx +++ b/web/src/components/plan/ChatInput.tsx @@ -4,7 +4,7 @@ import { useSessionStore, useIsRunning, useQueuedMessages } from '../../stores/s import { useScopedPaneState } from '../../stores/session/session-scope' import { useResource } from '../../hooks/useResource' import { useWorkflows } from '../../hooks/useWorkflows' -import { commandsResource, commandResource } from '../../lib/resources' +import { commandsResource, commandResource, skillsResource, selectActiveSkills } from '../../lib/resources' import { authFetch } from '../../lib/api' import { parseSlashCommand, extractTemplateParams } from '../../lib/parse-slash-command' import { insertSuggestionAtCursor, focusTextareaAt, resolveSlashParamIds } from '../../lib/composer-utils' @@ -103,6 +103,7 @@ export function ChatInput({ }: ChatInputProps) { const t = useT() const textareaRef = useRef(null) + const composerWrapRef = useRef(null) const fileInputRef = useRef(null) const prevLenRef = useRef(0) const cursorPosRef = useRef(0) @@ -147,6 +148,8 @@ export function ChatInput({ ? dedupById(dedupById(commandsData.defaults, commandsData.userItems), commandsData.projectItems) : [] const { workflows } = useWorkflows(workdir) + const { data: skillsData } = useResource(skillsResource, workdir) + const activeSkills = selectActiveSkills(skillsData) // Clear inline param hints when input is emptied (after send, escape, etc.) useEffect(() => { @@ -617,6 +620,7 @@ export function ChatInput({ />
{activeSlashParams.length > 0 && diff --git a/web/src/components/settings/CRUDListItem.tsx b/web/src/components/settings/CRUDListItem.tsx index 19661437..c57313eb 100644 --- a/web/src/components/settings/CRUDListItem.tsx +++ b/web/src/components/settings/CRUDListItem.tsx @@ -67,6 +67,7 @@ export interface CRUDListItemSimpleProps { id: string name: string description?: string + extraBadge?: ReactNode isBuiltIn: boolean isConfirmingDelete: boolean onView?: () => void @@ -81,6 +82,7 @@ export function CRUDListItemSimple({ id, name, description, + extraBadge, isBuiltIn, isConfirmingDelete, onView, @@ -104,6 +106,7 @@ export function CRUDListItemSimple({
{name} {id} + {extraBadge}
{description &&

{description}

} diff --git a/web/src/components/settings/SkillListItem.tsx b/web/src/components/settings/SkillListItem.tsx index b7c91d73..987c7959 100644 --- a/web/src/components/settings/SkillListItem.tsx +++ b/web/src/components/settings/SkillListItem.tsx @@ -1,6 +1,8 @@ import type { SkillInfo } from '../../lib/skills-actions' import { Toggle } from '../shared/Toggle' import { CRUDListItemSimple } from './CRUDListItem' +import { formatTokens } from '../../lib/mcp-utils' +import { useT } from '../../hooks/useT' interface SkillListItemProps { skill: SkillInfo @@ -25,18 +27,33 @@ export function SkillListItem({ onToggle, readOnly = false, }: SkillListItemProps) { + const t = useT() + return ( 0 ? ( + + {t({ en: '{{tokens}} tokens', fr: '{{tokens}} tokens' }, { tokens: formatTokens(skill.estimatedTokens) })} + + ) : undefined + } isBuiltIn={isBuiltIn} isConfirmingDelete={isConfirmingDelete} onView={onView} onEdit={readOnly ? undefined : onEdit} onDuplicate={onDuplicate} onDelete={readOnly ? undefined : onDelete} - actions={} + actions={ + + } /> ) } diff --git a/web/src/components/settings/SkillsModal.test.tsx b/web/src/components/settings/SkillsModal.test.tsx index 62bae3c1..7cb024bd 100644 --- a/web/src/components/settings/SkillsModal.test.tsx +++ b/web/src/components/settings/SkillsModal.test.tsx @@ -8,6 +8,7 @@ import { clearCache } from '../../lib/resourceCache' import { skillsResource } from '../../lib/resources' import { authFetch } from '../../lib/api' import { SkillsContent } from './SkillsModal' +import { setLocale } from '@shared/i18n/index.js' ;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -68,6 +69,8 @@ describe('SkillsContent', () => { beforeEach(async () => { vi.clearAllMocks() clearCache() + setLocale('en') + useSessionStore.setState({ currentSession: null }) seedSkills() await skillsResource.refresh() }) @@ -87,7 +90,8 @@ describe('SkillsContent', () => { it('requires modal confirmation before deleting the full skill folder', async () => { render() - fireEvent.click(screen.getByTitle('Delete')) + const deleteBtn = screen.getByRole('button', { name: /delete/i }) + fireEvent.click(deleteBtn) expect(screen.getByText('This skill files will be deleted.')).toBeTruthy() expect(screen.getByText('The full skill folder and all its contents will be removed.')).toBeTruthy() @@ -116,4 +120,82 @@ describe('SkillsContent', () => { expect(authFetch).toHaveBeenCalledWith('/api/skills?workdir=%2Foriginal%2Fproject') }) }) + + it('renders skills in subdirectories under collapsed-by-default collapsible sections and supports folder toggle', async () => { + const nestedSkill1: SkillInfo = { + id: 'nested-1', + name: 'Nested Skill 1', + description: 'Nested 1', + version: '1', + enabled: false, + group: 'dev-tools', + estimatedTokens: 120, + source: 'global-openfox', + path: '/tmp/skills/dev-tools/nested-1/SKILL.md', + legacy: false, + readOnly: false, + warnings: [], + } + const nestedSkill2: SkillInfo = { + id: 'nested-2', + name: 'Nested Skill 2', + description: 'Nested 2', + version: '1', + enabled: false, + group: 'dev-tools', + estimatedTokens: 80, + source: 'global-openfox', + path: '/tmp/skills/dev-tools/nested-2/SKILL.md', + legacy: false, + readOnly: false, + warnings: [], + } + vi.mocked(authFetch).mockImplementation( + async () => + ({ + ok: true, + json: async () => ({ + defaults: [], + userItems: [{ ...skill, estimatedTokens: 50 }, nestedSkill1, nestedSkill2], + projectItems: [], + items: [{ ...skill, estimatedTokens: 50 }, nestedSkill1, nestedSkill2], + selectedDirectory: null, + diagnostics: [], + }), + }) as unknown as Response, + ) + await skillsResource.refresh() + + render() + + await waitFor(() => { + expect(screen.getByText('My Skill')).toBeTruthy() + }) + + // Regular skill is rendered directly with its tokens + expect(screen.getByText(/50\s+tokens/)).toBeTruthy() + + // Subdirectory card header is rendered with name, skill count and total tokens + expect(screen.getByText('dev-tools')).toBeTruthy() + expect(screen.getByText('(2 skills)')).toBeTruthy() + expect(screen.getByText(/200\s+tokens/)).toBeTruthy() + + // Nested skills are collapsed by default + expect(screen.queryByText('Nested Skill 1')).toBeNull() + + // Expanding the group shows nested skills with their individual tokens + fireEvent.click(screen.getByText('dev-tools')) + expect(screen.getByText('Nested Skill 1')).toBeTruthy() + expect(screen.getByText(/120\s+tokens/)).toBeTruthy() + expect(screen.getByText('Nested Skill 2')).toBeTruthy() + expect(screen.getByText(/80\s+tokens/)).toBeTruthy() + + // Folder-level toggle activates all skills in the folder + const folderToggle = screen.getByRole('switch', { name: 'Toggle all skills in dev-tools' }) + expect(folderToggle.getAttribute('aria-checked')).toBe('false') + + fireEvent.click(folderToggle) + expect(mockToggleSkill).toHaveBeenCalledWith('nested-1', undefined) + expect(mockToggleSkill).toHaveBeenCalledWith('nested-2', undefined) + }) }) diff --git a/web/src/components/settings/SkillsModal.tsx b/web/src/components/settings/SkillsModal.tsx index 0e8a117c..1b1f4032 100644 --- a/web/src/components/settings/SkillsModal.tsx +++ b/web/src/components/settings/SkillsModal.tsx @@ -23,6 +23,9 @@ import { SkillLibraryPanel } from './SkillLibraryPanel' import { SkillListItem } from './SkillListItem' import { SkillDeleteModal } from './SkillDeleteModal' import { useT } from '../../hooks/useT' +import { Toggle } from '../shared/Toggle' +import { formatTokens } from '../../lib/mcp-utils' + type SkillFormData = { name: string id: string @@ -299,24 +302,122 @@ export function SkillsContent({ isOpen }: { isOpen: boolean }) { ) } - function EditableSkillItems({ items }: { items: SkillInfo[] }) { - return items.map((skill) => ( + function GroupedSkillItems({ items, isBuiltIn }: { items: SkillInfo[]; isBuiltIn?: boolean }) { + const ungrouped = items.filter((s) => !s.group) + const grouped = items.filter((s) => Boolean(s.group)) + const [expandedGroups, setExpandedGroups] = useState>(new Set()) + + const toggleGroup = (group: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev) + if (next.has(group)) next.delete(group) + else next.add(group) + return next + }) + } + + const groups = grouped.reduce>((acc, skill) => { + const g = skill.group! + if (!acc[g]) acc[g] = [] + acc[g]!.push(skill) + return acc + }, {}) + + const renderItem = (skill: SkillInfo) => ( handleView(skill.id)} - onEdit={() => handleEdit(skill.id)} + onEdit={!isBuiltIn ? () => handleEdit(skill.id) : undefined} onDuplicate={() => handleDuplicate(skill.id)} - onDelete={() => { - setDeleteError('') - setPendingDelete(skill) - }} + onDelete={ + !isBuiltIn + ? () => { + setDeleteError('') + setPendingDelete(skill) + } + : undefined + } onToggle={() => toggleSkill(skill.id, workdir)} readOnly={skill.readOnly} /> - )) + ) + + const groupNames = Object.keys(groups).sort() + + return ( +
+ {ungrouped.map(renderItem)} + {groupNames.map((g) => { + const groupSkills = groups[g]! + const isExpanded = expandedGroups.has(g) + const totalGroupTokens = groupSkills.reduce((sum, s) => sum + (s.estimatedTokens ?? 0), 0) + const allEnabled = groupSkills.length > 0 && groupSkills.every((s) => s.enabled) + + const handleToggleFolder = () => { + const targetState = !allEnabled + for (const s of groupSkills) { + if (s.enabled !== targetState) { + void toggleSkill(s.id, workdir) + } + } + } + + return ( +
+
toggleGroup(g)} + > +
+ {g} + + {t( + { + en: { one: '({{count}} skill)', other: '({{count}} skills)' }, + fr: { one: '({{count}} compétence)', other: '({{count}} compétences)' }, + }, + { count: groupSkills.length }, + )} + + {totalGroupTokens > 0 && ( + + {t( + { en: '{{tokens}} tokens', fr: '{{tokens}} tokens' }, + { tokens: formatTokens(totalGroupTokens) }, + )} + + )} +
+
e.stopPropagation()}> + + toggleGroup(g)}> + {isExpanded ? '▲' : '▼'} + +
+
+ {isExpanded && ( +
+ {groupSkills.map(renderItem)} +
+ )} +
+ ) + })} +
+ ) } return ( @@ -357,30 +458,20 @@ export function SkillsContent({ isOpen }: { isOpen: boolean }) { > {defaults.length > 0 && ( - {defaults.map((skill) => ( - handleView(skill.id)} - onDuplicate={() => handleDuplicate(skill.id)} - onToggle={() => toggleSkill(skill.id, workdir)} - /> - ))} + )} {userItems.length > 0 && ( - + )} {items.some((skill) => ['global-shared', 'selected', 'project-shared'].includes(skill.source)) && (
- ['global-shared', 'selected', 'project-shared'].includes(skill.source))} /> @@ -390,7 +481,7 @@ export function SkillsContent({ isOpen }: { isOpen: boolean }) { {projectItems.length > 0 && (
- +
)} diff --git a/web/src/components/settings/tabs/DisplayTab.test.tsx b/web/src/components/settings/tabs/DisplayTab.test.tsx index 5faf6574..fa61af4a 100644 --- a/web/src/components/settings/tabs/DisplayTab.test.tsx +++ b/web/src/components/settings/tabs/DisplayTab.test.tsx @@ -119,3 +119,30 @@ describe('DisplayTab Model Selector', () => { expect(mockSetSetting).toHaveBeenCalledWith(SETTINGS_KEYS.DISPLAY_COLLAPSE_FAVORITES_BY_DEFAULT, 'true') }) }) + +describe('DisplayTab Fullscreen slash commands', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.keys(mockSettings).forEach((k) => delete mockSettings[k]) + setLocale('en') + }) + + it('renders fullscreen slash commands toggle with proper description in English', () => { + render() + expect(screen.getByText('Fullscreen slash commands view')).toBeTruthy() + expect( + screen.getByText('Choose whether the commands view uses default sizing or fills the available screen height.'), + ).toBeTruthy() + }) + + it('renders fullscreen slash commands toggle with proper description in French', () => { + setLocale('fr') + render() + expect(screen.getByText('Vue plein écran des commandes slash')).toBeTruthy() + expect( + screen.getByText( + 'Choisissez si la vue des commandes utilise la taille par défaut ou remplit la hauteur d’écran disponible.', + ), + ).toBeTruthy() + }) +}) diff --git a/web/src/components/settings/tabs/DisplayTab.tsx b/web/src/components/settings/tabs/DisplayTab.tsx index 6981b0bc..31d9308a 100644 --- a/web/src/components/settings/tabs/DisplayTab.tsx +++ b/web/src/components/settings/tabs/DisplayTab.tsx @@ -66,6 +66,15 @@ const FEED_TOGGLES: ToggleDefinition[] = [ fr: 'Affiche les marqueurs de début et de fin de workflow', }, }, + { + key: SETTINGS_KEYS.DISPLAY_FULLSCREEN_SLASH_COMMAND, + label: { en: 'Fullscreen slash commands view', fr: 'Vue plein écran des commandes slash' }, + description: { + en: 'Choose whether the commands view uses default sizing or fills the available screen height.', + fr: 'Choisissez si la vue des commandes utilise la taille par défaut ou remplit la hauteur d’écran disponible.', + }, + defaultValue: 'false', + }, ] const PERF_TOGGLES: ToggleDefinition[] = [ @@ -142,6 +151,7 @@ export function DisplayTab() { const showStats = useSetting(SETTINGS_KEYS.DISPLAY_SHOW_STATS, 'true') const showAgentDefinitions = useSetting(SETTINGS_KEYS.DISPLAY_SHOW_AGENT_DEFINITIONS, 'true') const showWorkflowBars = useSetting(SETTINGS_KEYS.DISPLAY_SHOW_WORKFLOW_BARS, 'true') + const fullscreenSlashCommand = useSetting(SETTINGS_KEYS.DISPLAY_FULLSCREEN_SLASH_COMMAND, 'false') const nativeScrollbars = useSetting(SETTINGS_KEYS.DISPLAY_USE_NATIVE_SCROLLBARS, 'false') const nativeScrollbarsCodeBlocks = useSetting(SETTINGS_KEYS.DISPLAY_USE_NATIVE_SCROLLBARS_CODE_BLOCKS, 'false') const collapseLargeToolCalls = useSetting(SETTINGS_KEYS.DISPLAY_COLLAPSE_LARGE_TOOL_CALLS, 'false') @@ -176,6 +186,7 @@ export function DisplayTab() { [SETTINGS_KEYS.DISPLAY_SHOW_STATS]: showStats.value, [SETTINGS_KEYS.DISPLAY_SHOW_AGENT_DEFINITIONS]: showAgentDefinitions.value, [SETTINGS_KEYS.DISPLAY_SHOW_WORKFLOW_BARS]: showWorkflowBars.value, + [SETTINGS_KEYS.DISPLAY_FULLSCREEN_SLASH_COMMAND]: fullscreenSlashCommand.value, [SETTINGS_KEYS.DISPLAY_USE_NATIVE_SCROLLBARS]: nativeScrollbars.value, [SETTINGS_KEYS.DISPLAY_USE_NATIVE_SCROLLBARS_CODE_BLOCKS]: nativeScrollbarsCodeBlocks.value, [SETTINGS_KEYS.DISPLAY_COLLAPSE_LARGE_TOOL_CALLS]: collapseLargeToolCalls.value, diff --git a/web/src/components/shared/SlashAutocomplete.test.tsx b/web/src/components/shared/SlashAutocomplete.test.tsx index 228daecb..a3e49924 100644 --- a/web/src/components/shared/SlashAutocomplete.test.tsx +++ b/web/src/components/shared/SlashAutocomplete.test.tsx @@ -1,10 +1,18 @@ // @vitest-environment happy-dom -import { describe, expect, it, vi, afterEach } from 'vitest' +import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest' import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react' -import { SlashAutocomplete } from './SlashAutocomplete' +import { SlashAutocomplete, type SkillSlashInfo } from './SlashAutocomplete' +import { SETTINGS_KEYS } from '../../lib/resources' +import { setLocale } from '@shared/i18n/index.js' import type { WorkflowInfo } from '../../lib/parse-slash-command' import type { CommandInfo } from '../../lib/parse-slash-command' +const mockSettings: Record = {} + +vi.mock('../../hooks/useSetting', () => ({ + useSetting: (key: string, fallback = '') => ({ value: mockSettings[key] ?? fallback, loading: false }), +})) + const workflows: WorkflowInfo[] = [ { id: 'review', @@ -21,10 +29,15 @@ const commands: CommandInfo[] = [ { id: 'greet', name: 'Greet' }, ] +const skills: SkillSlashInfo[] = [ + { id: 'caveman', name: 'Caveman Mode', description: 'Terse communication style' }, + { id: 'browser', name: 'Browser Skill' }, +] + function renderAutocomplete( text: string, cursorPos: number, - overrides: { workflows?: WorkflowInfo[]; commands?: CommandInfo[] } = {}, + overrides: { workflows?: WorkflowInfo[]; commands?: CommandInfo[]; skills?: SkillSlashInfo[] } = {}, ) { return render( , ) } describe('SlashAutocomplete', () => { - afterEach(() => cleanup()) + beforeEach(() => { + vi.clearAllMocks() + Object.keys(mockSettings).forEach((k) => delete mockSettings[k]) + setLocale('en') + }) + + afterEach(() => { + cleanup() + }) it('renders nothing when no slash at cursor', () => { const { container } = renderAutocomplete('hello', 5) @@ -66,10 +88,18 @@ describe('SlashAutocomplete', () => { expect(screen.queryByText('Project')).toBeNull() }) - it('shows param count badge for parameterized items', () => { + it('shows param count badge for parameterized items in English', () => { const { container } = renderAutocomplete('/rev', 4) const badges = container.querySelectorAll('[class*="rounded"]') - const paramBadge = Array.from(badges).find((b) => b.textContent === '1 param') + const paramBadge = Array.from(badges).find((b) => b.textContent?.includes('1 param')) + expect(paramBadge).toBeDefined() + }) + + it('shows param count badge for parameterized items in French', () => { + setLocale('fr') + const { container } = renderAutocomplete('/rev', 4) + const badges = container.querySelectorAll('[class*="rounded"]') + const paramBadge = Array.from(badges).find((b) => b.textContent?.includes('1 paramètre')) expect(paramBadge).toBeDefined() }) @@ -83,6 +113,30 @@ describe('SlashAutocomplete', () => { expect(screen.getByText('/summarize')).toBeDefined() }) + it('matches skills and renders with text-accent-success and Skill badge in English', () => { + renderAutocomplete('/cave', 5) + const cmdLabel = screen.getByText('/caveman') + expect(cmdLabel).toBeDefined() + expect(cmdLabel.className).toContain('text-accent-success') + expect(screen.getByText('Caveman Mode')).toBeDefined() + expect(screen.getByText('Skill')).toBeDefined() + }) + + it('matches skills and renders with Compétence badge in French', () => { + setLocale('fr') + renderAutocomplete('/cave', 5) + const cmdLabel = screen.getByText('/caveman') + expect(cmdLabel).toBeDefined() + expect(cmdLabel.className).toContain('text-accent-success') + expect(screen.getByText('Caveman Mode')).toBeDefined() + expect(screen.getByText('Compétence')).toBeDefined() + }) + + it('matches skills by description', () => { + renderAutocomplete('/terse', 6) + expect(screen.getByText('/caveman')).toBeDefined() + }) + it('carries the scope on selected workflow suggestions', () => { const onSelect = vi.fn() const { container } = render( @@ -103,12 +157,16 @@ describe('SlashAutocomplete', () => { ) }) - it('renders in place with absolute positioning when no anchorRef is given', () => { + it('renders fullscreen sizing 10px under header when DISPLAY_FULLSCREEN_SLASH_COMMAND is true', () => { + mockSettings[SETTINGS_KEYS.DISPLAY_FULLSCREEN_SLASH_COMMAND] = 'true' + const { container } = renderAutocomplete('/rev', 4) - const listbox = container.querySelector('[role="listbox"]') + const listbox = container.querySelector('[role="listbox"]') as HTMLElement expect(listbox).toBeTruthy() - expect(listbox!.className).toContain('absolute') - expect(listbox!.className).toContain('bottom-full') + expect(listbox.style.top).toBe('42px') + expect(listbox.style.bottom).toBe('84px') + const scrollArea = listbox.querySelector('[class*="bg-bg-secondary"]') + expect(scrollArea).toBeTruthy() }) it('renders into a portal with fixed positioning when an anchorRef is given', () => { diff --git a/web/src/components/shared/SlashAutocomplete.tsx b/web/src/components/shared/SlashAutocomplete.tsx index 5eb15bc5..d3cbf6b6 100644 --- a/web/src/components/shared/SlashAutocomplete.tsx +++ b/web/src/components/shared/SlashAutocomplete.tsx @@ -4,20 +4,30 @@ import { createPortal } from 'react-dom' import { useFloatingPanel } from '../../hooks/useFloatingPanel' import { getSlashAtCursor } from '../../lib/getSlashAtCursor' import { SCOPE_LABELS } from '../../lib/workflow-scope' +import { SETTINGS_KEYS } from '../../lib/resources' +import { useSetting } from '../../hooks/useSetting' import type { WorkflowInfo } from '../../lib/parse-slash-command' import type { CommandInfo } from '../../lib/parse-slash-command' import type { WorkflowScope } from '@shared/types.js' import { useT } from '../../hooks/useT' +export interface SkillSlashInfo { + id: string + name: string + description?: string +} + export type SlashSuggestion = | { type: 'workflow'; id: string; name: string; scope: WorkflowScope; paramCount: number } | { type: 'command'; id: string; name: string; paramCount: number } + | { type: 'skill'; id: string; name: string; description?: string } interface SlashAutocompleteProps { text: string cursorPos: number workflows: WorkflowInfo[] commands: CommandInfo[] + skills?: SkillSlashInfo[] onSelect: (suggestion: SlashSuggestion, startIndex: number) => void /** * When provided, the dropdown renders into a portal fixed to this anchor @@ -32,7 +42,7 @@ export interface SlashAutocompleteHandle { } const SlashAutocomplete = forwardRef(function SlashAutocomplete( - { text, cursorPos, workflows, commands, onSelect, anchorRef }, + { text, cursorPos, workflows, commands, skills = [], onSelect, anchorRef }, ref, ) { const t = useT() @@ -72,7 +82,20 @@ const SlashAutocomplete = forwardRef + s.id.toLowerCase().includes(q) || + s.name.toLowerCase().includes(q) || + (s.description && s.description.toLowerCase().includes(q)), + ) + .map((s) => ({ + type: 'skill' as const, + id: s.id, + name: s.name, + description: s.description, + })) + return [...wf, ...cmd, ...skl] })() // Reset selection when suggestions change @@ -125,6 +148,7 @@ const SlashAutocomplete = forwardRef ({ handleKeyDown }), [handleKeyDown]) const { panelRef, layout } = useFloatingPanel(anchorRef, !!slash && suggestions.length > 0) + const isFullscreen = useSetting(SETTINGS_KEYS.DISPLAY_FULLSCREEN_SLASH_COMMAND, 'false').value === 'true' if (!slash || suggestions.length === 0) return null @@ -145,7 +169,15 @@ const SlashAutocomplete = forwardRef - + /{item.id} {item.name} @@ -154,7 +186,12 @@ const SlashAutocomplete = forwardRef )} - {item.paramCount > 0 && ( + {item.type === 'skill' && ( + + {t({ en: 'Skill', fr: 'Compétence' })} + + )} + {item.type !== 'skill' && item.paramCount > 0 && ( {item.paramCount}{' '} {t( @@ -168,6 +205,57 @@ const SlashAutocomplete = forwardRef ) + if (isFullscreen) { + const targetTop = (() => { + if (typeof document !== 'undefined') { + const topHeader = document.querySelector('header') + if (topHeader) { + return Math.round(topHeader.getBoundingClientRect().bottom + 10) + } + } + return 42 + })() + + if (anchorRef) { + const panel = ( +
+
+ {itemsMarkup} +
+
+ ) + return createPortal(panel, document.body) + } + + return ( +
+
+ {itemsMarkup} +
+
+ ) + } + if (anchorRef) { const panel = (
- - {itemsMarkup} - +
+ {itemsMarkup} +
) return createPortal(panel, document.body) @@ -186,9 +274,9 @@ const SlashAutocomplete = forwardRef - - {itemsMarkup} - +
+ {itemsMarkup} +
) }) diff --git a/web/src/components/tasks/TaskEditor.tsx b/web/src/components/tasks/TaskEditor.tsx index 088a0648..1674377d 100644 --- a/web/src/components/tasks/TaskEditor.tsx +++ b/web/src/components/tasks/TaskEditor.tsx @@ -14,7 +14,14 @@ import { useTasksStore } from '../../stores/tasks' import { useAgents } from '../../hooks/useAgents' import { useProviders } from '../../hooks/useProviders' import { useResource } from '../../hooks/useResource' -import { commandsResource, projectResource, workflowsResource, selectAllWorkflows } from '../../lib/resources' +import { + commandsResource, + projectResource, + workflowsResource, + skillsResource, + selectAllWorkflows, + selectActiveSkills, +} from '../../lib/resources' import { useProjectStore } from '../../stores/project' import { useProjects } from '../../hooks/useProjects' import { dedupById } from '../../lib/modal-utils' @@ -58,6 +65,7 @@ export function TaskEditor({ projectId, initialTask, onClose, onSaved }: TaskEdi const agents = allAgents.filter((a) => !a.subagent) const { data: commandsData } = useResource(commandsResource, workdir) const { data: workflowsData } = useResource(workflowsResource, workdir) + const { data: skillsData } = useResource(skillsResource, workdir) // Agents, commands, and workflows all load via the resource cache // (implicit loadership) — no imperative fetch to remember here. @@ -286,6 +294,7 @@ export function TaskEditor({ projectId, initialTask, onClose, onSaved }: TaskEdi const commands = commandsData ? dedupById(dedupById(commandsData.defaults, commandsData.userItems), commandsData.projectItems) : [] + const skills = selectActiveSkills(skillsData) const slashParamCount = (() => { if (activeSlashParams.length === 0) return 0 @@ -379,6 +388,7 @@ export function TaskEditor({ projectId, initialTask, onClose, onSaved }: TaskEdi cursorPos={cursorPosRef.current} workflows={workflows} commands={commands} + skills={skills} anchorRef={composerWrapRef} onSelect={handleSelectSlash} /> diff --git a/web/src/lib/resources.ts b/web/src/lib/resources.ts index c8a62f59..15cd3c90 100644 --- a/web/src/lib/resources.ts +++ b/web/src/lib/resources.ts @@ -231,6 +231,12 @@ export function readSkills(workdir?: string): SkillsData | undefined { return snapshot(skillsResource.keyOf(workdir)).data } +export function selectActiveSkills(data?: SkillsData): SkillInfo[] { + if (!data) return [] + const all = data.items.length > 0 ? data.items : [...data.defaults, ...data.userItems, ...data.projectItems] + return all.filter((sk) => sk.enabled) +} + export async function fetchSkill(skillId: string, workdir?: string): Promise { const res = await authFetch(scopedUrl(`/api/skills/${skillId}`, workdir)) if (!res.ok) return null @@ -597,6 +603,7 @@ export const SETTINGS_KEYS = { DISPLAY_COLLAPSE_PROVIDERS_BY_DEFAULT: 'display.collapseProvidersByDefault', DISPLAY_COLLAPSE_FAVORITES_BY_DEFAULT: 'display.collapseFavoritesByDefault', DISPLAY_MODEL_FAVORITES: 'display.modelFavorites', + DISPLAY_FULLSCREEN_SLASH_COMMAND: 'display.fullscreenSlashCommand', LLM_DYNAMIC_SYSTEM_PROMPT: 'llm.dynamicSystemPrompt', CACHE_WARMING: 'cache.warming', KEYBINDINGS: 'keybindings', @@ -631,6 +638,7 @@ export const DISPLAY_SETTINGS_KEYS = [ SETTINGS_KEYS.DISPLAY_COLLAPSE_LARGE_TOOL_CALLS, SETTINGS_KEYS.DISPLAY_DEFER_CODE_HIGHLIGHT_WHILE_STREAMING, SETTINGS_KEYS.DISPLAY_FEED_VIRTUALIZATION, + SETTINGS_KEYS.DISPLAY_FULLSCREEN_SLASH_COMMAND, ] as const export async function fetchChangelog(since?: string): Promise { diff --git a/web/src/lib/skills-actions.ts b/web/src/lib/skills-actions.ts index 7ae00e8a..5a81fe55 100644 --- a/web/src/lib/skills-actions.ts +++ b/web/src/lib/skills-actions.ts @@ -17,6 +17,8 @@ export interface SkillInfo { name: string description: string version: string + group?: string + estimatedTokens?: number enabled: boolean source: SkillSource path: string | null