diff --git a/hub/src/skills/codexSkills.test.ts b/hub/src/skills/codexSkills.test.ts new file mode 100644 index 0000000000..e894b93fad --- /dev/null +++ b/hub/src/skills/codexSkills.test.ts @@ -0,0 +1,276 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { parseSkillMetadata, scanCodexSkillsForSession } from './codexSkills' + +async function writeSkill(skillDir: string, name?: string, description?: string): Promise { + await mkdir(skillDir, { recursive: true }) + + const frontmatter = name || description + ? [ + '---', + name ? `name: ${name}` : '', + description ? `description: ${description}` : '', + '---', + '', + ].filter(Boolean).join('\n') + : '' + + await writeFile(join(skillDir, 'SKILL.md'), `${frontmatter}${name ? `# ${name}` : '# Fallback'}\n`) +} + +async function writePluginManifest(pluginRoot: string, name = 'compound-engineering', skills = './skills/'): Promise { + await mkdir(join(pluginRoot, '.codex-plugin'), { recursive: true }) + await writeFile(join(pluginRoot, '.codex-plugin', 'plugin.json'), JSON.stringify({ + name, + skills, + })) +} + +describe('codex skill discovery', () => { + const originalHome = process.env.HOME + const originalCodexHome = process.env.CODEX_HOME + let sandboxDir: string + let homeDir: string + let adminDir: string + + beforeEach(async () => { + sandboxDir = await mkdtemp(join(tmpdir(), 'hapi-codex-skills-')) + homeDir = join(sandboxDir, 'home') + adminDir = join(sandboxDir, 'admin-skills') + process.env.HOME = homeDir + delete process.env.CODEX_HOME + await mkdir(homeDir, { recursive: true }) + }) + + afterEach(async () => { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } + + await rm(sandboxDir, { recursive: true, force: true }) + }) + + it('scans user skills from ~/.agents/skills', async () => { + await writeSkill(join(homeDir, '.agents', 'skills', 'review'), 'review', 'Review code changes.') + + const skills = await scanCodexSkillsForSession(undefined, { adminSkillsRoot: adminDir }) + + expect(skills).toEqual([ + { + name: 'review', + description: 'Review code changes.', + path: join(homeDir, '.agents', 'skills', 'review', 'SKILL.md'), + scope: 'user', + }, + ]) + }) + + it('scans user skills from CODEX_HOME/skills when CODEX_HOME is set', async () => { + const codexHome = join(sandboxDir, 'codex-home') + process.env.CODEX_HOME = codexHome + await writeSkill(join(codexHome, 'skills', 'plan'), 'plan', 'Plan work.') + + const skills = await scanCodexSkillsForSession(undefined, { adminSkillsRoot: adminDir }) + + expect(skills.map((skill) => skill.name)).toEqual(['plan']) + expect(skills[0]?.scope).toBe('user') + }) + + it('scans plugin skills from CODEX_HOME/plugins/cache', async () => { + const codexHome = join(sandboxDir, 'codex-home') + const pluginRoot = join(codexHome, 'plugins', 'cache', 'compound-engineering-plugin', 'compound-engineering', '3.6.1') + process.env.CODEX_HOME = codexHome + await writePluginManifest(pluginRoot, 'compound-engineering') + await writeSkill(join(pluginRoot, 'skills', 'ce-plan'), 'ce-plan', 'Plan work.') + + const skills = await scanCodexSkillsForSession(undefined, { homeDir, adminSkillsRoot: adminDir }) + + expect(skills).toEqual([ + { + name: 'compound-engineering:ce-plan', + description: 'Plan work.', + path: join(pluginRoot, 'skills', 'ce-plan', 'SKILL.md'), + scope: 'plugin', + pluginName: 'compound-engineering', + pluginPath: pluginRoot, + }, + ]) + }) + + it('falls back to ~/.codex/plugins/cache when CODEX_HOME is not set', async () => { + const pluginRoot = join(homeDir, '.codex', 'plugins', 'cache', 'compound-engineering-plugin', 'compound-engineering', '3.6.1') + await writePluginManifest(pluginRoot, 'compound-engineering') + await writeSkill(join(pluginRoot, 'skills', 'ce-work'), 'ce-work', 'Execute work.') + + const skills = await scanCodexSkillsForSession(undefined, { adminSkillsRoot: adminDir }) + + expect(skills.map((skill) => `${skill.scope}:${skill.name}`)).toEqual([ + 'plugin:compound-engineering:ce-work', + ]) + }) + + it('uses plugin skill frontmatter names when they differ from folder names', async () => { + const codexHome = join(sandboxDir, 'codex-home') + const pluginRoot = join(codexHome, 'plugins', 'cache', 'example-plugin', 'example', '1.0.0') + process.env.CODEX_HOME = codexHome + await writePluginManifest(pluginRoot, 'example') + await writeSkill(join(pluginRoot, 'skills', 'folder-name'), 'frontmatter-name', 'From frontmatter.') + + const skills = await scanCodexSkillsForSession(undefined, { homeDir, adminSkillsRoot: adminDir }) + + expect(skills.map((skill) => skill.name)).toEqual(['example:frontmatter-name']) + }) + + it('skips plugin manifests with missing skills directories', async () => { + const codexHome = join(sandboxDir, 'codex-home') + const pluginRoot = join(codexHome, 'plugins', 'cache', 'empty-plugin', 'empty', '1.0.0') + process.env.CODEX_HOME = codexHome + await writePluginManifest(pluginRoot, 'empty') + + const skills = await scanCodexSkillsForSession(undefined, { homeDir, adminSkillsRoot: adminDir }) + + expect(skills).toEqual([]) + }) + + it('skips malformed plugin manifests', async () => { + const codexHome = join(sandboxDir, 'codex-home') + const pluginRoot = join(codexHome, 'plugins', 'cache', 'bad-plugin', 'bad', '1.0.0') + process.env.CODEX_HOME = codexHome + await mkdir(join(pluginRoot, '.codex-plugin'), { recursive: true }) + await writeFile(join(pluginRoot, '.codex-plugin', 'plugin.json'), '{bad json') + await writeSkill(join(pluginRoot, 'skills', 'ignored'), 'ignored', 'Ignored.') + + const skills = await scanCodexSkillsForSession(undefined, { homeDir, adminSkillsRoot: adminDir }) + + expect(skills).toEqual([]) + }) + + it('scans repo skills from cwd up to git root', async () => { + const repoRoot = join(sandboxDir, 'repo') + const packageDir = join(repoRoot, 'packages') + const workingDirectory = join(packageDir, 'app') + + await mkdir(join(repoRoot, '.git'), { recursive: true }) + await writeSkill(join(repoRoot, '.agents', 'skills', 'root'), 'root', 'Root skill.') + await writeSkill(join(packageDir, '.codex', 'skills', 'package'), 'package', 'Package skill.') + await writeSkill(join(workingDirectory, '.agents', 'skills', 'local'), 'local', 'Local skill.') + await writeSkill(join(sandboxDir, '.agents', 'skills', 'outside'), 'outside', 'Outside skill.') + + const skills = await scanCodexSkillsForSession(workingDirectory, { homeDir, adminSkillsRoot: adminDir }) + + expect(skills.map((skill) => `${skill.scope}:${skill.name}`)).toEqual([ + 'repo:local', + 'repo:package', + 'repo:root', + ]) + }) + + it('uses only cwd repo skills when no git root exists', async () => { + const parentDirectory = join(sandboxDir, 'workspace') + const workingDirectory = join(parentDirectory, 'feature') + + await writeSkill(join(parentDirectory, '.agents', 'skills', 'parent'), 'parent', 'Parent skill.') + await writeSkill(join(workingDirectory, '.agents', 'skills', 'local'), 'local', 'Local skill.') + + const skills = await scanCodexSkillsForSession(workingDirectory, { homeDir, adminSkillsRoot: adminDir }) + + expect(skills.map((skill) => skill.name)).toEqual(['local']) + }) + + it('parses SKILL.md frontmatter', async () => { + const skillDir = join(homeDir, '.agents', 'skills', 'review-folder') + await writeSkill(skillDir, 'review', 'Review current code changes.') + + const skill = await parseSkillMetadata(join(skillDir, 'SKILL.md'), 'review-folder', 'user') + + expect(skill).toMatchObject({ + name: 'review', + description: 'Review current code changes.', + scope: 'user', + }) + }) + + it('falls back to directory name when frontmatter is missing', async () => { + const skillDir = join(homeDir, '.agents', 'skills', 'no-frontmatter') + await mkdir(skillDir, { recursive: true }) + await writeFile(join(skillDir, 'SKILL.md'), '# First paragraph\n\nMore details that stay server-side.\n') + + const skills = await scanCodexSkillsForSession(undefined, { adminSkillsRoot: adminDir }) + + expect(skills).toEqual([ + { + name: 'no-frontmatter', + description: 'First paragraph', + path: join(skillDir, 'SKILL.md'), + scope: 'user', + }, + ]) + }) + + it('silently skips missing directories', async () => { + await expect(scanCodexSkillsForSession(join(sandboxDir, 'missing'), { + homeDir: join(sandboxDir, 'missing-home'), + adminSkillsRoot: join(sandboxDir, 'missing-admin'), + })).resolves.toEqual([]) + }) + + it('keeps duplicate skill names from different scopes', async () => { + const repoRoot = join(sandboxDir, 'repo') + await mkdir(join(repoRoot, '.git'), { recursive: true }) + await writeSkill(join(repoRoot, '.agents', 'skills', 'review'), 'review', 'Repo review.') + await writeSkill(join(homeDir, '.agents', 'skills', 'review'), 'review', 'User review.') + const pluginRoot = join(homeDir, '.codex', 'plugins', 'cache', 'review-plugin', 'review-plugin', '1.0.0') + await writePluginManifest(pluginRoot, 'review-plugin') + await writeSkill(join(pluginRoot, 'skills', 'review'), 'review', 'Plugin review.') + + const skills = await scanCodexSkillsForSession(repoRoot, { homeDir, adminSkillsRoot: adminDir }) + + expect(skills.map((skill) => `${skill.scope}:${skill.name}:${skill.description}`)).toEqual([ + 'repo:review:Repo review.', + 'user:review:User review.', + 'plugin:review-plugin:review:Plugin review.', + ]) + }) + + it('sorts repo, user, plugin, and admin skills by scope group', async () => { + const repoRoot = join(sandboxDir, 'repo') + await mkdir(join(repoRoot, '.git'), { recursive: true }) + await writeSkill(join(repoRoot, '.agents', 'skills', 'repo'), 'repo', 'Repo skill.') + await writeSkill(join(homeDir, '.agents', 'skills', 'user'), 'user', 'User skill.') + await writeSkill(join(adminDir, 'admin'), 'admin', 'Admin skill.') + const pluginRoot = join(homeDir, '.codex', 'plugins', 'cache', 'plugin-package', 'plugin', '1.0.0') + await writePluginManifest(pluginRoot, 'plugin') + await writeSkill(join(pluginRoot, 'skills', 'plugin-skill'), 'plugin-skill', 'Plugin skill.') + + const skills = await scanCodexSkillsForSession(repoRoot, { homeDir, adminSkillsRoot: adminDir }) + + expect(skills.map((skill) => `${skill.scope}:${skill.name}`)).toEqual([ + 'repo:repo', + 'user:user', + 'plugin:plugin:plugin-skill', + 'admin:admin', + ]) + }) + + it('supports symlinked skill folders', async () => { + const targetDir = join(sandboxDir, 'shared-review') + const linkDir = join(homeDir, '.agents', 'skills', 'linked-review') + await writeSkill(targetDir, 'linked-review', 'Linked skill.') + await mkdir(join(homeDir, '.agents', 'skills'), { recursive: true }) + await symlink(targetDir, linkDir) + + const skills = await scanCodexSkillsForSession(undefined, { adminSkillsRoot: adminDir }) + + expect(skills.map((skill) => skill.name)).toEqual(['linked-review']) + }) +}) diff --git a/hub/src/skills/codexSkills.ts b/hub/src/skills/codexSkills.ts new file mode 100644 index 0000000000..a72ca23c87 --- /dev/null +++ b/hub/src/skills/codexSkills.ts @@ -0,0 +1,351 @@ +import { access, open, readFile, readdir, stat } from 'node:fs/promises' +import { basename, dirname, join, resolve } from 'node:path' +import { homedir } from 'node:os' +import type { Dirent } from 'node:fs' + +export type CodexSkillScope = 'repo' | 'user' | 'plugin' | 'admin' + +export interface CodexSkillSummary { + name: string + description: string + path: string + scope: CodexSkillScope + pluginName?: string + pluginPath?: string +} + +export interface CodexSkillScanOptions { + homeDir?: string + codexHome?: string + adminSkillsRoot?: string +} + +const SKILL_HEADER_BYTES = 16 * 1024 +const PLUGIN_MANIFEST_RELATIVE_PATH = join('.codex-plugin', 'plugin.json') + +function getHomeDirectory(options: CodexSkillScanOptions): string { + return options.homeDir ?? process.env.HOME ?? process.env.USERPROFILE ?? homedir() +} + +function getCodexHome(options: CodexSkillScanOptions): string { + return options.codexHome ?? process.env.CODEX_HOME ?? join(getHomeDirectory(options), '.codex') +} + +async function pathExists(path: string): Promise { + try { + await access(path) + return true + } catch { + return false + } +} + +async function findGitRoot(workingDirectory: string): Promise { + let current = resolve(workingDirectory) + + while (true) { + if (await pathExists(join(current, '.git'))) { + return current + } + + const parent = dirname(current) + if (parent === current) { + return null + } + current = parent + } +} + +async function getRepoSkillsRoots(workingDirectory?: string): Promise { + if (!workingDirectory) { + return [] + } + + const cwd = resolve(workingDirectory) + const gitRoot = await findGitRoot(cwd) + const directories: string[] = [] + let current = cwd + + while (true) { + directories.push(current) + if (!gitRoot || current === gitRoot) { + break + } + + const parent = dirname(current) + if (parent === current) { + break + } + current = parent + } + + return directories.flatMap((directory) => [ + join(directory, '.agents', 'skills'), + join(directory, '.codex', 'skills'), + ]) +} + +function getUserSkillsRoots(options: CodexSkillScanOptions): string[] { + const roots = [join(getHomeDirectory(options), '.agents', 'skills')] + const codexHome = options.codexHome ?? process.env.CODEX_HOME + if (codexHome) { + roots.push(join(codexHome, 'skills')) + } + return roots +} + +function getPluginCacheRoot(options: CodexSkillScanOptions): string { + return join(getCodexHome(options), 'plugins', 'cache') +} + +function getAdminSkillsRoot(options: CodexSkillScanOptions): string { + return options.adminSkillsRoot ?? join('/etc', 'codex', 'skills') +} + +async function listSkillDirectories(skillsRoot: string): Promise { + try { + const entries = await readdir(skillsRoot, { withFileTypes: true }) + const directories = await Promise.all(entries.map(async (entry) => { + if (entry.name.startsWith('.')) { + return null + } + + const fullPath = join(skillsRoot, entry.name) + try { + const entryStat = entry.isDirectory() + ? null + : await stat(fullPath) + if (entry.isDirectory() || entryStat?.isDirectory()) { + return fullPath + } + } catch { + return null + } + + return null + })) + return directories.filter((directory): directory is string => directory !== null) + } catch { + return [] + } +} + +async function readSkillHeader(skillMdPath: string): Promise { + let file: Awaited> | null = null + try { + file = await open(skillMdPath, 'r') + const buffer = Buffer.alloc(SKILL_HEADER_BYTES) + const result = await file.read(buffer, 0, SKILL_HEADER_BYTES, 0) + return buffer.subarray(0, result.bytesRead).toString('utf8') + } catch { + return null + } finally { + await file?.close().catch(() => undefined) + } +} + +function unquoteYamlScalar(value: string): string { + const trimmed = value.trim() + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) + || (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).trim() + } + return trimmed +} + +function parseFrontmatterHeader(header: string): { name?: string; description?: string; bodyStart: string } { + const match = header.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)/) + if (!match) { + return { bodyStart: header } + } + + const metadata: { name?: string; description?: string } = {} + const yaml = match[1] ?? '' + for (const line of yaml.split(/\r?\n/)) { + const scalarMatch = line.match(/^\s*(name|description)\s*:\s*(.*?)\s*$/) + if (!scalarMatch) { + continue + } + + const key = scalarMatch[1] as 'name' | 'description' + const value = unquoteYamlScalar(scalarMatch[2] ?? '') + if (value) { + metadata[key] = value + } + } + + return { ...metadata, bodyStart: match[2] ?? '' } +} + +function firstParagraphDescription(bodyStart: string): string { + const paragraph = bodyStart + .split(/\r?\n\s*\r?\n/) + .map((part) => part.trim()) + .find((part) => part.length > 0) + + if (!paragraph) { + return '' + } + + return paragraph + .replace(/^#+\s+/, '') + .replace(/\s+/g, ' ') + .slice(0, 240) + .trim() +} + +export async function parseSkillMetadata( + skillMdPath: string, + fallbackName: string, + scope: CodexSkillScope, + options: { + namePrefix?: string + pluginName?: string + pluginPath?: string + } = {} +): Promise { + const header = await readSkillHeader(skillMdPath) + if (header === null) { + return null + } + + const parsed = parseFrontmatterHeader(header) + const name = (parsed.name ?? fallbackName).trim() + if (!name) { + return null + } + + const displayName = options.namePrefix ? `${options.namePrefix}:${name}` : name + + return { + name: displayName, + description: parsed.description?.trim() ?? firstParagraphDescription(parsed.bodyStart), + path: skillMdPath, + scope, + pluginName: options.pluginName, + pluginPath: options.pluginPath, + } +} + +async function readSkillsFromRoot(skillsRoot: string, scope: CodexSkillScope): Promise { + const skillDirs = await listSkillDirectories(skillsRoot) + const skills = await Promise.all(skillDirs.map(async (skillDir) => ( + await parseSkillMetadata(join(skillDir, 'SKILL.md'), basename(skillDir), scope) + ))) + + return skills + .filter((skill): skill is CodexSkillSummary => skill !== null) + .sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)) +} + +async function readSkillsFromRoots(roots: string[], scope: CodexSkillScope): Promise { + const skillsByRoot = await Promise.all(roots.map((root) => readSkillsFromRoot(root, scope))) + return skillsByRoot.flat().sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)) +} + +interface CodexPluginManifest { + name: string + skillsDir: string + pluginPath: string +} + +function isJsonRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +async function parsePluginManifest(manifestPath: string): Promise { + try { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as unknown + if (!isJsonRecord(manifest) || typeof manifest.name !== 'string' || !manifest.name.trim()) { + return null + } + + const pluginPath = dirname(dirname(manifestPath)) + const skillsValue = typeof manifest.skills === 'string' && manifest.skills.trim() + ? manifest.skills + : './skills' + + return { + name: manifest.name.trim(), + skillsDir: resolve(pluginPath, skillsValue), + pluginPath, + } + } catch { + return null + } +} + +async function findPluginManifestPaths(pluginCacheRoot: string): Promise { + const manifests: string[] = [] + + async function visit(directory: string): Promise { + let entries: Dirent[] + try { + entries = await readdir(directory, { withFileTypes: true }) + } catch { + return + } + + if (entries.some((entry) => entry.isDirectory() && entry.name === '.codex-plugin')) { + const manifestPath = join(directory, PLUGIN_MANIFEST_RELATIVE_PATH) + if (await pathExists(manifestPath)) { + manifests.push(manifestPath) + return + } + } + + await Promise.all(entries.map(async (entry) => { + if (!entry.isDirectory() || entry.name.startsWith('.')) { + return + } + await visit(join(directory, entry.name)) + })) + } + + await visit(pluginCacheRoot) + return manifests.sort((a, b) => a.localeCompare(b)) +} + +async function readPluginSkills(manifest: CodexPluginManifest): Promise { + const skillDirs = await listSkillDirectories(manifest.skillsDir) + const skills = await Promise.all(skillDirs.map(async (skillDir) => ( + await parseSkillMetadata(join(skillDir, 'SKILL.md'), basename(skillDir), 'plugin', { + namePrefix: manifest.name, + pluginName: manifest.name, + pluginPath: manifest.pluginPath, + }) + ))) + + return skills + .filter((skill): skill is CodexSkillSummary => skill !== null) + .sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)) +} + +async function readPluginSkillsFromCache(pluginCacheRoot: string): Promise { + const manifestPaths = await findPluginManifestPaths(pluginCacheRoot) + const manifests = (await Promise.all(manifestPaths.map(parsePluginManifest))) + .filter((manifest): manifest is CodexPluginManifest => manifest !== null) + const skillsByPlugin = await Promise.all(manifests.map(readPluginSkills)) + return skillsByPlugin.flat().sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)) +} + +export async function scanCodexSkillsForSession( + workingDirectory?: string, + options: CodexSkillScanOptions = {} +): Promise { + const repoRoots = await getRepoSkillsRoots(workingDirectory) + const userRoots = getUserSkillsRoots(options) + const pluginCacheRoot = getPluginCacheRoot(options) + const adminRoot = getAdminSkillsRoot(options) + + const [repoSkills, userSkills, pluginSkills, adminSkills] = await Promise.all([ + readSkillsFromRoots(repoRoots, 'repo'), + readSkillsFromRoots(userRoots, 'user'), + readPluginSkillsFromCache(pluginCacheRoot), + readSkillsFromRoot(adminRoot, 'admin'), + ]) + + return [...repoSkills, ...userSkills, ...pluginSkills, ...adminSkills] +} diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index fc29e2931b..a5745339e2 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -1,9 +1,33 @@ import { describe, expect, it } from 'bun:test' import { Hono } from 'hono' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { Session, SyncEngine } from '../../sync/syncEngine' import type { WebAppEnv } from '../middleware/auth' import { createSessionsRoutes } from './sessions' +async function writeSkill(skillDir: string, name: string, description: string): Promise { + await mkdir(skillDir, { recursive: true }) + await writeFile(join(skillDir, 'SKILL.md'), [ + '---', + `name: ${name}`, + `description: ${description}`, + '---', + '', + `# ${name}`, + '', + ].join('\n')) +} + +async function writePluginManifest(pluginRoot: string, name: string): Promise { + await mkdir(join(pluginRoot, '.codex-plugin'), { recursive: true }) + await writeFile(join(pluginRoot, '.codex-plugin', 'plugin.json'), JSON.stringify({ + name, + skills: './skills/', + })) +} + function createSession(overrides?: Partial): Session { const baseMetadata = { path: '/tmp/project', @@ -91,6 +115,81 @@ function createApp(session: Session, opts?: { } describe('sessions routes', () => { + it('returns plugin skills for Codex sessions', async () => { + const originalHome = process.env.HOME + const originalCodexHome = process.env.CODEX_HOME + const sandboxDir = await mkdtemp(join(tmpdir(), 'hapi-session-skills-')) + try { + const homeDir = join(sandboxDir, 'home') + const codexHome = join(sandboxDir, 'codex-home') + const projectDir = join(sandboxDir, 'project') + const pluginRoot = join(codexHome, 'plugins', 'cache', 'compound-engineering-plugin', 'compound-engineering', '3.6.1') + process.env.HOME = homeDir + process.env.CODEX_HOME = codexHome + await mkdir(join(projectDir, '.git'), { recursive: true }) + await writeSkill(join(projectDir, '.agents', 'skills', 'review'), 'review', 'Review code.') + await writePluginManifest(pluginRoot, 'compound-engineering') + await writeSkill(join(pluginRoot, 'skills', 'ce-plan'), 'ce-plan', 'Plan work.') + + const { app } = createApp(createSession({ + metadata: { + path: projectDir, + host: 'localhost', + flavor: 'codex' + } + })) + + const response = await app.request('/api/sessions/session-1/skills') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual([ + { + name: 'review', + description: 'Review code.', + path: join(projectDir, '.agents', 'skills', 'review', 'SKILL.md'), + scope: 'repo', + }, + { + name: 'compound-engineering:ce-plan', + description: 'Plan work.', + path: join(pluginRoot, 'skills', 'ce-plan', 'SKILL.md'), + scope: 'plugin', + pluginName: 'compound-engineering', + pluginPath: pluginRoot, + }, + ]) + } finally { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } + + await rm(sandboxDir, { recursive: true, force: true }) + } + }) + + it('returns no skills for non-Codex sessions', async () => { + const { app } = createApp(createSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'claude' + } + })) + + const response = await app.request('/api/sessions/session-1/skills') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual([]) + }) + it('rejects collaboration mode changes for local Codex sessions', async () => { const session = createSession({ agentState: { diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 96148e38a7..9c36c85a2c 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -3,6 +3,7 @@ import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protoc import { Hono } from 'hono' import { z } from 'zod' import type { SyncEngine, Session } from '../../sync/syncEngine' +import { scanCodexSkillsForSession } from '../../skills/codexSkills' import type { WebAppEnv } from '../middleware/auth' import { requireSessionFromParam, requireSyncEngine } from './guards' @@ -521,14 +522,15 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return sessionResult } + if (sessionResult.session.metadata?.flavor !== 'codex') { + return c.json([]) + } + try { - const result = await engine.listSkills(sessionResult.sessionId) - return c.json(result) - } catch (error) { - return c.json({ - success: false, - error: error instanceof Error ? error.message : 'Failed to list skills' - }) + const skills = await scanCodexSkillsForSession(sessionResult.session.metadata?.path) + return c.json(skills) + } catch { + return c.json([]) } }) diff --git a/web/src/components/AssistantChat/ComposerButtons.test.tsx b/web/src/components/AssistantChat/ComposerButtons.test.tsx new file mode 100644 index 0000000000..e4202bb2fe --- /dev/null +++ b/web/src/components/AssistantChat/ComposerButtons.test.tsx @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type React from 'react' +import { ComposerButtons } from './ComposerButtons' + +vi.mock('@assistant-ui/react', () => ({ + ComposerPrimitive: { + AddAttachment: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), + }, +})) + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +function renderButtons(overrides: Partial[0]> = {}) { + const props: Parameters[0] = { + canSend: false, + controlsDisabled: false, + showSettingsButton: false, + onSettingsToggle: vi.fn(), + showTerminalButton: false, + terminalDisabled: false, + terminalLabel: 'Terminal', + onTerminal: vi.fn(), + showAbortButton: false, + abortDisabled: false, + isAborting: false, + onAbort: vi.fn(), + showSwitchButton: false, + switchDisabled: false, + isSwitching: false, + onSwitch: vi.fn(), + voiceEnabled: false, + voiceStatus: 'disconnected', + onVoiceToggle: vi.fn(), + onSend: vi.fn(), + ...overrides, + } + + render() + return props +} + +describe('ComposerButtons', () => { + afterEach(() => { + cleanup() + }) + + it('shows a mobile skill picker button when enabled', () => { + const onSkillPickerOpen = vi.fn() + renderButtons({ + showSkillPickerButton: true, + onSkillPickerOpen, + }) + + const button = screen.getByRole('button', { name: 'composer.skills' }) + expect(button).toHaveClass('sm:hidden') + + fireEvent.click(button) + expect(onSkillPickerOpen).toHaveBeenCalled() + }) + + it('omits the skill picker button when disabled by caller', () => { + renderButtons({ showSkillPickerButton: false }) + + expect(screen.queryByRole('button', { name: 'composer.skills' })).not.toBeInTheDocument() + }) + + it('disables the skill picker button with composer controls', () => { + renderButtons({ + showSkillPickerButton: true, + controlsDisabled: true, + }) + + expect(screen.getByRole('button', { name: 'composer.skills' })).toBeDisabled() + }) + + it('shows a mobile continue prompt button when enabled', () => { + const onContinuePromptOpen = vi.fn() + renderButtons({ + showContinuePromptButton: true, + onContinuePromptOpen, + }) + + const button = screen.getByRole('button', { name: 'composer.continueShortcut.label' }) + expect(button).toHaveClass('sm:hidden') + + fireEvent.click(button) + expect(onContinuePromptOpen).toHaveBeenCalled() + }) + + it('disables the continue prompt button with composer controls', () => { + renderButtons({ + showContinuePromptButton: true, + controlsDisabled: true, + }) + + expect(screen.getByRole('button', { name: 'composer.continueShortcut.label' })).toBeDisabled() + }) +}) diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 2777c9056f..743fbe8c35 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -143,6 +143,22 @@ function AttachmentIcon() { ) } +function DollarIcon() { + return ( + + ) +} + +function ContinuePromptIcon() { + return ( + + ) +} + function AbortIcon(props: { spinning: boolean }) { if (props.spinning) { return ( @@ -318,6 +334,12 @@ export function ComposerButtons(props: { voiceMicMuted?: boolean onVoiceToggle: () => void onVoiceMicToggle?: () => void + showSkillPickerButton?: boolean + skillPickerDisabled?: boolean + onSkillPickerOpen?: () => void + showContinuePromptButton?: boolean + continuePromptDisabled?: boolean + onContinuePromptOpen?: () => void onSend: () => void }) { const { t } = useTranslation() @@ -335,6 +357,32 @@ export function ComposerButtons(props: { + {props.showSkillPickerButton ? ( + + ) : null} + + {props.showContinuePromptButton ? ( + + ) : null} + {props.showSettingsButton ? ( + + + + + ) +} diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index b5d34da46e..1cd60e045c 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -12,8 +12,9 @@ import { useRef, useState } from 'react' -import type { AgentState, CodexCollaborationMode, PermissionMode } from '@/types/api' +import type { AgentState, CodexCollaborationMode, PermissionMode, SkillSummary } from '@/types/api' import type { Suggestion } from '@/hooks/useActiveSuggestions' +import type { SkillSearchResult } from '@/lib/skill-search' import type { ConversationStatus } from '@/realtime/types' import { useActiveWord } from '@/hooks/useActiveWord' import { useActiveSuggestions } from '@/hooks/useActiveSuggestions' @@ -25,8 +26,10 @@ import { markSkillUsed } from '@/lib/recent-skills' import { useComposerDraft } from '@/hooks/useComposerDraft' import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' import { Autocomplete } from '@/components/ChatInput/Autocomplete' +import { SkillPickerDialog } from '@/components/AssistantChat/SkillPickerDialog' import { StatusBar } from '@/components/AssistantChat/StatusBar' import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons' +import { CONTINUE_PROMPT, ContinuePromptDialog } from '@/components/AssistantChat/ContinuePromptDialog' import { AttachmentItem } from '@/components/AssistantChat/AttachmentItem' import { useTranslation } from '@/lib/use-translation' import { getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions' @@ -38,6 +41,12 @@ export interface TextInputState { selection: { start: number; end: number } } +type SkillPickerAnchor = { + text: string + selection: { start: number; end: number } + query: string +} + const defaultSuggestionHandler = async (): Promise => [] export function HappyComposer(props: { @@ -69,6 +78,9 @@ export function HappyComposer(props: { terminalUnsupported?: boolean autocompletePrefixes?: string[] autocompleteSuggestions?: (query: string) => Promise + availableSkills?: readonly SkillSummary[] + refreshSkills?: () => Promise + onQuickSendPrompt?: (text: string) => void // Voice assistant props voiceStatus?: ConversationStatus voiceMicMuted?: boolean @@ -105,6 +117,9 @@ export function HappyComposer(props: { terminalUnsupported = false, autocompletePrefixes = ['@', '/', '$'], autocompleteSuggestions = defaultSuggestionHandler, + availableSkills = [], + refreshSkills, + onQuickSendPrompt, voiceStatus = 'disconnected', voiceMicMuted = false, onVoiceToggle, @@ -148,6 +163,8 @@ export function HappyComposer(props: { const [isAborting, setIsAborting] = useState(false) const [isSwitching, setIsSwitching] = useState(false) const [showContinueHint, setShowContinueHint] = useState(false) + const [skillPickerAnchor, setSkillPickerAnchor] = useState(null) + const [continuePromptDialogOpen, setContinuePromptDialogOpen] = useState(false) const textareaRef = useRef(null) const prevControlledByUser = useRef(controlledByUser) @@ -179,7 +196,11 @@ export function HappyComposer(props: { const { isStandalone, isIOS } = usePWAInstall() const isIOSPWA = isIOS && isStandalone const bottomPaddingClass = isIOSPWA ? 'pb-0' : 'pb-3' - const activeWord = useActiveWord(inputState.text, inputState.selection, autocompletePrefixes) + const compactAutocompletePrefixes = useMemo( + () => autocompletePrefixes.filter((prefix) => prefix !== '$'), + [autocompletePrefixes] + ) + const activeWord = useActiveWord(inputState.text, inputState.selection, compactAutocompletePrefixes) const [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions] = useActiveSuggestions( activeWord, autocompleteSuggestions, @@ -199,15 +220,12 @@ export function HappyComposer(props: { const handleSuggestionSelect = useCallback((index: number) => { const suggestion = suggestions[index] if (!suggestion || !textareaRef.current) return - if (suggestion.text.startsWith('$')) { - markSkillUsed(suggestion.text.slice(1)) - } const result = applySuggestion( inputState.text, inputState.selection, suggestion.text, - autocompletePrefixes, + compactAutocompletePrefixes, true ) @@ -229,7 +247,76 @@ export function HappyComposer(props: { }, 0) haptic('light') - }, [api, suggestions, inputState, autocompletePrefixes, haptic]) + }, [api, suggestions, inputState, compactAutocompletePrefixes, haptic]) + + const restoreTextareaFocus = useCallback((cursorPosition?: number) => { + setTimeout(() => { + const el = textareaRef.current + if (!el) return + if (cursorPosition !== undefined) { + el.setSelectionRange(cursorPosition, cursorPosition) + } + try { + el.focus({ preventScroll: true }) + } catch { + el.focus() + } + }, 0) + }, []) + + const handleSkillPickerClose = useCallback(() => { + setSkillPickerAnchor(null) + restoreTextareaFocus() + }, [restoreTextareaFocus]) + + const handleSkillSelect = useCallback((suggestion: SkillSearchResult) => { + if (!skillPickerAnchor) return + markSkillUsed(suggestion) + + const result = applySuggestion( + skillPickerAnchor.text, + skillPickerAnchor.selection, + suggestion.text, + ['$'], + true + ) + + api.composer().setText(result.text) + setInputState({ + text: result.text, + selection: { start: result.cursorPosition, end: result.cursorPosition } + }) + setSkillPickerAnchor(null) + restoreTextareaFocus(result.cursorPosition) + haptic('light') + }, [api, haptic, restoreTextareaFocus, skillPickerAnchor]) + + const handleSkillPickerShortcut = useCallback(() => { + if (controlsDisabled || agentFlavor !== 'codex') return + clearSuggestions() + const el = textareaRef.current + const selection = el + ? { start: el.selectionStart, end: el.selectionEnd } + : inputState.selection + setSkillPickerAnchor({ + text: inputState.text, + selection, + query: '', + }) + haptic('light') + }, [agentFlavor, clearSuggestions, controlsDisabled, haptic, inputState.selection, inputState.text]) + + const handleContinuePromptShortcut = useCallback(() => { + if (controlsDisabled || !onQuickSendPrompt) return + setContinuePromptDialogOpen(true) + haptic('light') + }, [controlsDisabled, haptic, onQuickSendPrompt]) + + const handleContinuePromptConfirm = useCallback(() => { + if (controlsDisabled || !onQuickSendPrompt) return + onQuickSendPrompt(CONTINUE_PROMPT) + haptic('success') + }, [controlsDisabled, haptic, onQuickSendPrompt]) const abortDisabled = controlsDisabled || isAborting || !threadIsRunning const switchDisabled = controlsDisabled || isSwitching || !controlledByUser @@ -814,10 +901,29 @@ export function HappyComposer(props: { voiceMicMuted={voiceMicMuted} onVoiceToggle={onVoiceToggle ?? (() => {})} onVoiceMicToggle={onVoiceMicToggle} + showSkillPickerButton={agentFlavor === 'codex'} + skillPickerDisabled={controlsDisabled} + onSkillPickerOpen={handleSkillPickerShortcut} + showContinuePromptButton={Boolean(onQuickSendPrompt)} + continuePromptDisabled={controlsDisabled} + onContinuePromptOpen={handleContinuePromptShortcut} onSend={handleSend} /> + + ) diff --git a/web/src/components/AssistantChat/SkillPickerDialog.test.tsx b/web/src/components/AssistantChat/SkillPickerDialog.test.tsx new file mode 100644 index 0000000000..df1779e4df --- /dev/null +++ b/web/src/components/AssistantChat/SkillPickerDialog.test.tsx @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { SkillSummary } from '@/types/api' +import { markSkillUsed } from '@/lib/recent-skills' +import { skillToSearchResult } from '@/lib/skill-search' +import { SkillPickerDialog } from './SkillPickerDialog' + +const reviewSkill: SkillSummary = { + name: 'review', + description: 'Review code changes.', + scope: 'repo', + path: '/repo/review/SKILL.md', +} + +const planSkill: SkillSummary = { + name: 'compound-engineering:ce-plan', + description: 'Create structured plans.', + scope: 'plugin', + pluginName: 'compound-engineering', + path: '/plugin/ce-plan/SKILL.md', +} + +describe('SkillPickerDialog', () => { + afterEach(() => { + cleanup() + }) + + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + Element.prototype.scrollIntoView = vi.fn() + window.requestAnimationFrame = (callback: FrameRequestCallback) => { + callback(0) + return 0 + } + }) + + it('loads suggestions from the initial query', async () => { + const refreshSkills = vi.fn(async () => [reviewSkill, planSkill]) + + render( + + ) + + expect(screen.getByPlaceholderText('Search skills')).toHaveValue('pla') + expect(await screen.findByText('$compound-engineering:ce-plan')).toBeInTheDocument() + expect(refreshSkills).toHaveBeenCalled() + }) + + it('refines results when the search query changes', async () => { + render( + + ) + + fireEvent.change(screen.getByPlaceholderText('Search skills'), { target: { value: 'work' } }) + + await waitFor(() => expect(screen.queryByText('$compound-engineering:ce-plan')).not.toBeInTheDocument()) + expect(screen.queryByText('$review')).not.toBeInTheDocument() + expect(screen.getByText('No matching skills')).toBeInTheDocument() + }) + + it('defaults empty query opens to Recent with an empty state', async () => { + render( + + ) + + expect(screen.getByRole('tab', { name: 'Recent' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByText('No recent skills')).toBeInTheDocument() + }) + + it('shows recent skills on the Recent tab', async () => { + markSkillUsed(skillToSearchResult(planSkill)) + + render( + + ) + + expect(screen.getByRole('tab', { name: 'Recent' })).toHaveAttribute('aria-selected', 'true') + expect(await screen.findByText('$compound-engineering:ce-plan')).toBeInTheDocument() + expect(screen.queryByText('$review')).not.toBeInTheDocument() + }) + + it('switches to All when the search query changes', async () => { + markSkillUsed(skillToSearchResult(reviewSkill)) + + render( + + ) + + fireEvent.change(screen.getByPlaceholderText('Search skills'), { target: { value: 'pla' } }) + + expect(screen.getByRole('tab', { name: 'All' })).toHaveAttribute('aria-selected', 'true') + expect(await screen.findByText('$compound-engineering:ce-plan')).toBeInTheDocument() + expect(screen.queryByText('$review')).not.toBeInTheDocument() + }) + + it('selects the highlighted skill with Enter', async () => { + const onSelect = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('tab', { name: 'All' })) + await screen.findByText('$review') + fireEvent.keyDown(screen.getByPlaceholderText('Search skills'), { key: 'ArrowDown' }) + fireEvent.keyDown(screen.getByPlaceholderText('Search skills'), { key: 'Enter' }) + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ + text: '$compound-engineering:ce-plan', + })) + }) + + it('selects the highlighted skill with Tab', async () => { + const onSelect = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('tab', { name: 'All' })) + await screen.findByText('$review') + fireEvent.keyDown(screen.getByPlaceholderText('Search skills'), { key: 'Tab' }) + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ + text: '$review', + })) + }) + + it('closes without selecting on Escape', async () => { + const onClose = vi.fn() + const onSelect = vi.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('tab', { name: 'All' })) + await screen.findByText('$review') + fireEvent.keyDown(screen.getByPlaceholderText('Search skills'), { key: 'Escape' }) + + expect(onClose).toHaveBeenCalled() + expect(onSelect).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/components/AssistantChat/SkillPickerDialog.tsx b/web/src/components/AssistantChat/SkillPickerDialog.tsx new file mode 100644 index 0000000000..82b2f7bb87 --- /dev/null +++ b/web/src/components/AssistantChat/SkillPickerDialog.tsx @@ -0,0 +1,307 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import type { SkillSummary } from '@/types/api' +import { searchSkills, skillToSearchResult, type SkillSearchResult } from '@/lib/skill-search' +import { getRecentSkills, recentEntryToSkill, type RecentSkillEntry } from '@/lib/recent-skills' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' + +type SkillPickerDialogProps = { + open: boolean + initialQuery: string + skills: readonly SkillSummary[] + refreshSkills?: () => Promise + onSelect: (suggestion: SkillSearchResult) => void + onClose: () => void +} + +type SkillPickerTab = 'recent' | 'all' + +function initialTab(query: string): SkillPickerTab { + return query.trim() ? 'all' : 'recent' +} + +function scopeLabel(suggestion: SkillSearchResult): string { + return suggestion.scope.toUpperCase() +} + +function compactPath(path: string | undefined): string { + if (!path) return '' + const parts = path.split('/').filter(Boolean) + if (parts.length <= 4) { + return path + } + return `.../${parts.slice(-4).join('/')}` +} + +export function SkillPickerDialog(props: SkillPickerDialogProps) { + const { open, initialQuery, skills, refreshSkills, onSelect, onClose } = props + const [query, setQuery] = useState(initialQuery) + const [loadedSkills, setLoadedSkills] = useState(skills) + const [recentSkills, setRecentSkills] = useState([]) + const [activeTab, setActiveTab] = useState(() => initialTab(initialQuery)) + const [selectedIndex, setSelectedIndex] = useState(0) + const inputRef = useRef(null) + const listRef = useRef(null) + + useEffect(() => { + if (!open) return + setQuery(initialQuery) + setLoadedSkills(skills) + setRecentSkills(getRecentSkills()) + setActiveTab(initialTab(initialQuery)) + setSelectedIndex(0) + }, [open, initialQuery, skills]) + + useEffect(() => { + if (!open || !refreshSkills) return + + let cancelled = false + void refreshSkills().then((nextSkills) => { + if (cancelled) return + setLoadedSkills(nextSkills) + }).catch(() => { + if (cancelled) return + setLoadedSkills([]) + setSelectedIndex(-1) + }) + + return () => { + cancelled = true + } + }, [open, refreshSkills]) + + const allSuggestions = useMemo( + () => searchSkills(loadedSkills, `$${query}`), + [loadedSkills, query] + ) + const currentSkillResultsByKey = useMemo(() => { + const entries = loadedSkills.map((skill) => { + const result = skillToSearchResult(skill) + return [result.key, result] as const + }) + return new Map(entries) + }, [loadedSkills]) + const recentSuggestions = useMemo( + () => recentSkills.map((entry) => ( + currentSkillResultsByKey.get(entry.key) ?? skillToSearchResult(recentEntryToSkill(entry)) + )), + [currentSkillResultsByKey, recentSkills] + ) + const queryHasText = query.trim().length > 0 + const suggestions = activeTab === 'recent' && !queryHasText + ? recentSuggestions + : allSuggestions + + useEffect(() => { + if (queryHasText) { + setActiveTab('all') + } + }, [queryHasText]) + + useEffect(() => { + setSelectedIndex((current) => { + if (suggestions.length === 0) { + return -1 + } + if (current < 0) { + return 0 + } + return Math.min(current, suggestions.length - 1) + }) + }, [suggestions.length]) + + useEffect(() => { + if (!open) return + requestAnimationFrame(() => { + inputRef.current?.focus() + inputRef.current?.select() + }) + }, [open]) + + useEffect(() => { + if (selectedIndex < 0 || selectedIndex >= suggestions.length) return + const selectedEl = listRef.current?.querySelector( + `[data-skill-index="${selectedIndex}"]` + ) + selectedEl?.scrollIntoView({ block: 'nearest' }) + }, [selectedIndex, suggestions.length]) + + const selectedSuggestion = selectedIndex >= 0 ? suggestions[selectedIndex] : undefined + + const selectSuggestion = useCallback((suggestion: SkillSearchResult | undefined) => { + if (!suggestion) return + onSelect(suggestion) + }, [onSelect]) + + const handleKeyDown = useCallback((event: KeyboardEvent) => { + if (event.nativeEvent.isComposing) { + return + } + + if (event.key === 'ArrowDown') { + event.preventDefault() + setSelectedIndex((current) => { + if (suggestions.length === 0) return -1 + return current >= suggestions.length - 1 ? 0 : current + 1 + }) + return + } + + if (event.key === 'ArrowUp') { + event.preventDefault() + setSelectedIndex((current) => { + if (suggestions.length === 0) return -1 + return current <= 0 ? suggestions.length - 1 : current - 1 + }) + return + } + + if (event.key === 'Enter' || (event.key === 'Tab' && !event.shiftKey)) { + if (!selectedSuggestion) return + event.preventDefault() + selectSuggestion(selectedSuggestion) + return + } + + if (event.key === 'Escape') { + event.preventDefault() + onClose() + } + }, [onClose, selectSuggestion, selectedSuggestion, suggestions.length]) + + const resultCountLabel = useMemo(() => { + if (suggestions.length === 1) { + return '1 skill' + } + return `${suggestions.length} skills` + }, [suggestions.length]) + + return ( + { + if (!nextOpen) onClose() + }}> + + +
+
+ Skills +
{resultCountLabel}
+
+ +
+ setQuery(event.target.value)} + placeholder="Search skills" + className="mt-3 h-10 w-full rounded-md border border-[var(--app-divider)] bg-[var(--app-bg)] px-3 text-base text-[var(--app-fg)] outline-none focus:border-[var(--app-link)]" + /> +
+ + +
+
+ +
+ {suggestions.length === 0 ? ( +
+ {activeTab === 'recent' && !queryHasText ? 'No recent skills' : 'No matching skills'} +
+ ) : suggestions.map((suggestion, index) => { + const selected = index === selectedIndex + const path = compactPath(suggestion.path) + const description = suggestion.skill.description + return ( + + ) + })} +
+
+
+ ) +} diff --git a/web/src/components/ChatInput/Autocomplete.tsx b/web/src/components/ChatInput/Autocomplete.tsx index 07fe996874..fc37cf10e2 100644 --- a/web/src/components/ChatInput/Autocomplete.tsx +++ b/web/src/components/ChatInput/Autocomplete.tsx @@ -43,7 +43,18 @@ export const Autocomplete = memo(function Autocomplete(props: AutocompleteProps) onClick={() => onSelect(index)} onMouseDown={(e) => e.preventDefault()} // Prevent blur on textarea > - {suggestion.label} + + {suggestion.label} + {suggestion.scope ? ( + + {suggestion.scope} + + ) : null} + {suggestion.description && ( void onRetryMessage?: (localId: string) => void autocompleteSuggestions?: (query: string) => Promise + availableSkills?: readonly SkillSummary[] + refreshSkills?: () => Promise availableSlashCommands?: readonly SlashCommand[] }) { const { haptic } = usePlatform() @@ -504,6 +507,9 @@ export function SessionChat(props: { onTerminal={props.session.active && terminalSupported ? handleViewTerminal : undefined} terminalUnsupported={props.session.active && !terminalSupported} autocompleteSuggestions={props.autocompleteSuggestions} + availableSkills={props.availableSkills} + refreshSkills={props.refreshSkills} + onQuickSendPrompt={handleSend} voiceStatus={voice?.status} voiceMicMuted={voice?.micMuted} onVoiceToggle={voice ? handleVoiceToggle : undefined} diff --git a/web/src/components/assistant-ui/doc-path-copy.test.tsx b/web/src/components/assistant-ui/doc-path-copy.test.tsx new file mode 100644 index 0000000000..364c76841e --- /dev/null +++ b/web/src/components/assistant-ui/doc-path-copy.test.tsx @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { + CopyableDocPathText, + splitDocPathText, +} from '@/components/assistant-ui/doc-path-copy' + +const clipboardMocks = vi.hoisted(() => ({ + safeCopyToClipboard: vi.fn(), +})) + +vi.mock('@/lib/clipboard', () => ({ + safeCopyToClipboard: clipboardMocks.safeCopyToClipboard, +})) + +vi.mock('@/hooks/usePlatform', () => ({ + usePlatform: () => ({ + isTelegram: false, + isTouch: true, + haptic: { + impact: vi.fn(), + notification: vi.fn(), + selection: vi.fn(), + }, + }), +})) + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => ( + params?.path ? `${key} ${params.path}` : key + ), + }), +})) + +describe('splitDocPathText', () => { + it('splits a single docs markdown path', () => { + expect(splitDocPathText('Plan: docs/plans/example-plan.md ready')).toEqual([ + { type: 'text', value: 'Plan: ' }, + { type: 'path', value: 'docs/plans/example-plan.md' }, + { type: 'text', value: ' ready' }, + ]) + }) + + it('keeps trailing punctuation outside the path', () => { + expect(splitDocPathText('Use docs/plans/example-plan.md.')).toEqual([ + { type: 'text', value: 'Use ' }, + { type: 'path', value: 'docs/plans/example-plan.md' }, + { type: 'text', value: '.' }, + ]) + }) + + it('keeps multiple docs markdown paths distinct', () => { + expect(splitDocPathText('Read docs/brainstorms/a.md then docs/plans/b.md')).toEqual([ + { type: 'text', value: 'Read ' }, + { type: 'path', value: 'docs/brainstorms/a.md' }, + { type: 'text', value: ' then ' }, + { type: 'path', value: 'docs/plans/b.md' }, + ]) + }) + + it('ignores non-target paths', () => { + expect(splitDocPathText('See web/src/file.tsx, scratch/file.md, README.md, docs/plans/a.mdx, and docs/plans/a.md.bak')).toEqual([ + { type: 'text', value: 'See web/src/file.tsx, scratch/file.md, README.md, docs/plans/a.mdx, and docs/plans/a.md.bak' }, + ]) + }) +}) + +describe('CopyableDocPathText', () => { + afterEach(() => { + clipboardMocks.safeCopyToClipboard.mockReset() + cleanup() + }) + + it('copies only the matching path', async () => { + clipboardMocks.safeCopyToClipboard.mockResolvedValue(undefined) + + render() + + const button = screen.getByRole('button', { + name: 'markdown.copyDocPath docs/plans/example-plan.md', + }) + expect(button).toHaveClass('sm:hidden') + + fireEvent.click(button) + + await waitFor(() => { + expect(clipboardMocks.safeCopyToClipboard).toHaveBeenCalledWith('docs/plans/example-plan.md') + }) + }) + + it('renders one copy button per matched path', () => { + render() + + expect(screen.getByRole('button', { + name: 'markdown.copyDocPath docs/brainstorms/a.md', + })).toBeInTheDocument() + expect(screen.getByRole('button', { + name: 'markdown.copyDocPath docs/plans/b.md', + })).toBeInTheDocument() + }) + + it('does not throw when clipboard copy fails', async () => { + clipboardMocks.safeCopyToClipboard.mockRejectedValue(new Error('denied')) + + render() + + fireEvent.click(screen.getByRole('button', { + name: 'markdown.copyDocPath docs/plans/example-plan.md', + })) + + await waitFor(() => { + expect(clipboardMocks.safeCopyToClipboard).toHaveBeenCalledWith('docs/plans/example-plan.md') + }) + expect(screen.getByText('docs/plans/example-plan.md')).toBeInTheDocument() + }) +}) diff --git a/web/src/components/assistant-ui/doc-path-copy.tsx b/web/src/components/assistant-ui/doc-path-copy.tsx new file mode 100644 index 0000000000..1f2318f281 --- /dev/null +++ b/web/src/components/assistant-ui/doc-path-copy.tsx @@ -0,0 +1,80 @@ +import { Children, Fragment, type ReactNode } from 'react' +import { CheckIcon, CopyIcon } from '@/components/icons' +import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' +import { useTranslation } from '@/lib/use-translation' + +export type DocPathSegment = { + type: 'text' | 'path' + value: string +} + +const DOC_MARKDOWN_PATH_PATTERN = /docs\/[^\s`"'<>()[\]{}]+?\.md(?=$|[\s`"'<>()[\]{},;:!?]|\.(?:\s|$))/g + +export function splitDocPathText(text: string): DocPathSegment[] { + const segments: DocPathSegment[] = [] + let lastIndex = 0 + + for (const match of text.matchAll(DOC_MARKDOWN_PATH_PATTERN)) { + const path = match[0] + const index = match.index ?? 0 + if (index > lastIndex) { + segments.push({ type: 'text', value: text.slice(lastIndex, index) }) + } + segments.push({ type: 'path', value: path }) + lastIndex = index + path.length + } + + if (lastIndex < text.length) { + segments.push({ type: 'text', value: text.slice(lastIndex) }) + } + + return segments.length > 0 ? segments : [{ type: 'text', value: text }] +} + +function DocPathCopyButton(props: { path: string }) { + const { t } = useTranslation() + const { copied, copy } = useCopyToClipboard() + const label = t('markdown.copyDocPath', { path: props.path }) + + return ( + + ) +} + +export function CopyableDocPathText(props: { text: string }) { + const segments = splitDocPathText(props.text) + + return ( + <> + {segments.map((segment, index) => ( + + {segment.value} + {segment.type === 'path' ? : null} + + ))} + + ) +} + +export function renderDocPathCopyChildren(children: ReactNode): ReactNode { + return Children.map(children, (child) => { + if (typeof child === 'string') { + return + } + return child + }) +} diff --git a/web/src/components/assistant-ui/markdown-text.test.tsx b/web/src/components/assistant-ui/markdown-text.test.tsx new file mode 100644 index 0000000000..f541604645 --- /dev/null +++ b/web/src/components/assistant-ui/markdown-text.test.tsx @@ -0,0 +1,108 @@ +import type { ComponentType, HTMLAttributes } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' + +const markdownMocks = vi.hoisted(() => ({ + isCodeBlock: false, +})) + +vi.mock('@assistant-ui/react-markdown', async () => { + const actual = await vi.importActual('@assistant-ui/react-markdown') + return { + ...actual, + useIsMarkdownCodeBlock: () => markdownMocks.isCodeBlock, + } +}) + +vi.mock('@/hooks/usePlatform', () => ({ + usePlatform: () => ({ + isTelegram: false, + isTouch: true, + haptic: { + impact: vi.fn(), + notification: vi.fn(), + selection: vi.fn(), + }, + }), +})) + +vi.mock('@/lib/use-translation', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => ( + params?.path ? `${key} ${params.path}` : key + ), + }), +})) + +import { + assistantMessageComponents, + defaultComponents, +} from '@/components/assistant-ui/markdown-text' + +type TextComponent = ComponentType> + +function component(name: 'p' | 'code'): TextComponent { + return assistantMessageComponents[name] as TextComponent +} + +describe('assistant markdown doc path copy', () => { + afterEach(() => { + markdownMocks.isCodeBlock = false + cleanup() + }) + + it('adds a mobile copy button to docs markdown paths in normal text', () => { + const P = component('p') + + render(

Plan written: docs/plans/example-plan.md

) + + const button = screen.getByRole('button', { + name: 'markdown.copyDocPath docs/plans/example-plan.md', + }) + expect(button).toHaveClass('sm:hidden') + }) + + it('adds a mobile copy button to docs markdown paths in inline code', () => { + const Code = component('code') + + render(docs/plans/example-plan.md) + + expect(screen.getByRole('button', { + name: 'markdown.copyDocPath docs/plans/example-plan.md', + })).toBeInTheDocument() + }) + + it('does not add per-path copy buttons inside code blocks', () => { + markdownMocks.isCodeBlock = true + const Code = component('code') + + render(docs/plans/example-plan.md) + + expect(screen.queryByRole('button', { + name: 'markdown.copyDocPath docs/plans/example-plan.md', + })).not.toBeInTheDocument() + }) + + it('does not enhance default markdown components', () => { + const P = defaultComponents.p as TextComponent + + render(

Plan written: docs/plans/example-plan.md

) + + expect(screen.queryByRole('button', { + name: 'markdown.copyDocPath docs/plans/example-plan.md', + })).not.toBeInTheDocument() + }) + + it('adds independent controls for multiple docs markdown paths', () => { + const P = component('p') + + render(

Read docs/brainstorms/a.md and docs/plans/b.md

) + + expect(screen.getByRole('button', { + name: 'markdown.copyDocPath docs/brainstorms/a.md', + })).toBeInTheDocument() + expect(screen.getByRole('button', { + name: 'markdown.copyDocPath docs/plans/b.md', + })).toBeInTheDocument() + }) +}) diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx index 2774cd581b..499a741c47 100644 --- a/web/src/components/assistant-ui/markdown-text.tsx +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -17,6 +17,7 @@ import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter' import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import { CopyIcon, CheckIcon } from '@/components/icons' +import { renderDocPathCopyChildren } from '@/components/assistant-ui/doc-path-copy' import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown' @@ -68,24 +69,57 @@ function Pre(props: ComponentPropsWithoutRef<'pre'>) { function Code(props: ComponentPropsWithoutRef<'code'>) { const isCodeBlock = useIsMarkdownCodeBlock() + const { className, children, ...rest } = props if (isCodeBlock) { return ( + {...rest} + className={cn('aui-md-codeblockcode font-mono', className)} + > + {children} + ) } return ( + > + {children} + + ) +} + +function AssistantCode(props: ComponentPropsWithoutRef<'code'>) { + const isCodeBlock = useIsMarkdownCodeBlock() + const { className, children, ...rest } = props + + if (isCodeBlock) { + return ( + + {children} + + ) + } + + return ( + + {renderDocPathCopyChildren(children)} + ) } @@ -105,6 +139,15 @@ function Paragraph(props: ComponentPropsWithoutRef<'p'>) { return

} +function AssistantParagraph(props: ComponentPropsWithoutRef<'p'>) { + const { children, ...rest } = props + return ( +

+ {renderDocPathCopyChildren(children)} +

+ ) +} + function Blockquote(props: ComponentPropsWithoutRef<'blockquote'>) { return (
) { return
  • } +function AssistantListItem(props: ComponentPropsWithoutRef<'li'>) { + const { children, ...rest } = props + return ( +
  • + {renderDocPathCopyChildren(children)} +
  • + ) +} + function Hr(props: ComponentPropsWithoutRef<'hr'>) { return
    } @@ -167,10 +219,34 @@ function Th(props: ComponentPropsWithoutRef<'th'>) { ) } +function AssistantTh(props: ComponentPropsWithoutRef<'th'>) { + const { children, ...rest } = props + return ( + + {renderDocPathCopyChildren(children)} + + ) +} + function Td(props: ComponentPropsWithoutRef<'td'>) { return } +function AssistantTd(props: ComponentPropsWithoutRef<'td'>) { + const { children, ...rest } = props + return ( + + {renderDocPathCopyChildren(children)} + + ) +} + function H1(props: ComponentPropsWithoutRef<'h1'>) { return

    } @@ -236,12 +312,21 @@ export const defaultComponents = memoizeMarkdownComponents({ img: Image, } as const) +export const assistantMessageComponents = memoizeMarkdownComponents({ + ...defaultComponents, + code: AssistantCode, + p: AssistantParagraph, + li: AssistantListItem, + th: AssistantTh, + td: AssistantTd, +} as const) + export function MarkdownText() { return ( diff --git a/web/src/hooks/queries/useSkills.ts b/web/src/hooks/queries/useSkills.ts index b97dbc21e6..438fca4b41 100644 --- a/web/src/hooks/queries/useSkills.ts +++ b/web/src/hooks/queries/useSkills.ts @@ -4,31 +4,17 @@ import type { ApiClient } from '@/api/client' import type { SkillSummary } from '@/types/api' import type { Suggestion } from '@/hooks/useActiveSuggestions' import { queryKeys } from '@/lib/query-keys' -import { getRecentSkills } from '@/lib/recent-skills' - -function levenshteinDistance(a: string, b: string): number { - if (a.length === 0) return b.length - if (b.length === 0) return a.length - const matrix: number[][] = [] - for (let i = 0; i <= b.length; i++) matrix[i] = [i] - for (let j = 0; j <= a.length; j++) matrix[0][j] = j - for (let i = 1; i <= b.length; i++) { - for (let j = 1; j <= a.length; j++) { - matrix[i][j] = b[i - 1] === a[j - 1] - ? matrix[i - 1][j - 1] - : Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1) - } - } - return matrix[b.length][a.length] -} +import { searchSkills, skillSearchResultsToSuggestions } from '@/lib/skill-search' export function useSkills( api: ApiClient | null, - sessionId: string | null + sessionId: string | null, + enabled: boolean = true ): { skills: SkillSummary[] isLoading: boolean error: string | null + refreshSkills: () => Promise getSuggestions: (query: string) => Promise } { const resolvedSessionId = sessionId ?? 'unknown' @@ -41,67 +27,47 @@ export function useSkills( } return await api.getSkills(sessionId) }, - enabled: Boolean(api && sessionId), - staleTime: Infinity, + enabled: Boolean(api && sessionId && enabled), + staleTime: 0, gcTime: 30 * 60 * 1000, retry: false, }) const skills = useMemo(() => { - if (query.data?.success && query.data.skills) { - return query.data.skills + if (Array.isArray(query.data)) { + return query.data } return [] }, [query.data]) - const getSuggestions = useCallback(async (queryText: string): Promise => { - const recent = getRecentSkills() - const getRecency = (name: string) => recent[name] ?? 0 - const searchTerm = queryText.startsWith('$') - ? queryText.slice(1).toLowerCase() - : queryText.toLowerCase() + const refetchSkills = query.refetch - if (!searchTerm) { - return [...skills] - .sort((a, b) => getRecency(b.name) - getRecency(a.name) || a.name.localeCompare(b.name)) - .map((skill) => ({ - key: `$${skill.name}`, - text: `$${skill.name}`, - label: `$${skill.name}`, - description: skill.description, - source: 'builtin' - })) + const refreshSkills = useCallback(async (): Promise => { + if (!api || !sessionId || !enabled) { + return [] } - const maxDistance = Math.max(2, Math.floor(searchTerm.length / 2)) - return skills - .map(skill => { - const name = skill.name.toLowerCase() - let score: number - if (name === searchTerm) score = 0 - else if (name.startsWith(searchTerm)) score = 1 - else if (name.includes(searchTerm)) score = 2 - else { - const dist = levenshteinDistance(searchTerm, name) - score = dist <= maxDistance ? 3 + dist : Infinity - } - return { skill, score, recency: getRecency(skill.name) } - }) - .filter(item => item.score < Infinity) - .sort((a, b) => a.score - b.score || b.recency - a.recency || a.skill.name.localeCompare(b.skill.name)) - .map(({ skill }) => ({ - key: `$${skill.name}`, - text: `$${skill.name}`, - label: `$${skill.name}`, - description: skill.description, - source: 'builtin' - })) - }, [skills]) + return await refetchSkills({ throwOnError: false }) + .then((result) => Array.isArray(result.data) ? result.data : []) + .catch(() => []) + }, [api, sessionId, enabled, refetchSkills]) + + const getSuggestions = useCallback(async (queryText: string): Promise => { + if (!api || !sessionId || !enabled) { + return [] + } + + const loadedSkills = await refreshSkills() + return skillSearchResultsToSuggestions(searchSkills(loadedSkills, queryText)) + }, [api, sessionId, enabled, refreshSkills]) return { skills, isLoading: query.isLoading, error: query.error instanceof Error ? query.error.message : query.error ? 'Failed to load skills' : null, + refreshSkills, getSuggestions, } } + +export const useSessionSkills = useSkills diff --git a/web/src/hooks/useActiveSuggestions.ts b/web/src/hooks/useActiveSuggestions.ts index 7bd76df668..e249452d10 100644 --- a/web/src/hooks/useActiveSuggestions.ts +++ b/web/src/hooks/useActiveSuggestions.ts @@ -6,7 +6,9 @@ export interface Suggestion { label: string description?: string content?: string // Expanded content for Codex user prompts - source?: 'builtin' | 'user' | 'plugin' | 'project' + source?: 'builtin' | 'user' | 'plugin' | 'project' | 'repo' | 'admin' + path?: string + scope?: 'repo' | 'user' | 'plugin' | 'admin' } interface SuggestionOptions { diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index ddeddf6177..646dbe8bea 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -175,6 +175,7 @@ export default { // Code block 'code.copy': 'Copy', 'code.truncated': 'Preview truncated — open details for full output', + 'markdown.copyDocPath': 'Copy document path {path}', // Diff view 'diff.title': 'Diff', @@ -241,6 +242,11 @@ export default { 'composer.send': 'Send', 'composer.stop': 'Stop', 'composer.voice': 'Voice assistant', + 'composer.skills': 'Skills', + 'composer.continueShortcut.label': 'Agree and continue', + 'composer.continueShortcut.confirmTitle': 'Send quick prompt?', + 'composer.continueShortcut.confirmDescription': 'This will send the following prompt now:', + 'composer.continueShortcut.confirmSend': 'Send', 'composer.codexSlashUnsupported.title': 'Codex command unavailable', 'composer.codexSlashUnsupported.body': 'HAPI remote mode does not yet run built-in Codex slash commands like {command}. Use natural language instead, or run it in the local Codex TUI.', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index a5e44aa7ec..96048ed2a2 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -177,6 +177,7 @@ export default { // Code block 'code.copy': '复制', 'code.truncated': '预览已截断 — 打开详情查看完整输出', + 'markdown.copyDocPath': '复制文档路径 {path}', // Diff view 'diff.title': '差异', @@ -243,6 +244,11 @@ export default { 'composer.send': '发送', 'composer.stop': '停止', 'composer.voice': '语音助手', + 'composer.skills': '技能', + 'composer.continueShortcut.label': '同意, 继续', + 'composer.continueShortcut.confirmTitle': '发送快捷提示?', + 'composer.continueShortcut.confirmDescription': '将立即发送以下提示:', + 'composer.continueShortcut.confirmSend': '发送', 'composer.codexSlashUnsupported.title': '无法执行 Codex 命令', 'composer.codexSlashUnsupported.body': 'HAPI 远程模式暂不支持 {command} 这类 Codex 内建 slash command,请改用自然语言,或在本地 Codex TUI 中执行。', diff --git a/web/src/lib/recent-skills.test.ts b/web/src/lib/recent-skills.test.ts new file mode 100644 index 0000000000..556c905668 --- /dev/null +++ b/web/src/lib/recent-skills.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SkillSummary } from '@/types/api' +import { skillToSearchResult } from './skill-search' +import { + getRecentSkills, + markSkillUsed, + RECENT_SKILLS_KEY, +} from './recent-skills' + +function skill(overrides: Partial): SkillSummary { + return { + name: 'review', + description: 'Review code changes.', + path: '/repo/review/SKILL.md', + scope: 'repo', + ...overrides, + } +} + +function mark(skillSummary: SkillSummary): void { + markSkillUsed(skillToSearchResult(skillSummary)) +} + +describe('recent skills', () => { + beforeEach(() => { + localStorage.clear() + vi.useRealTimers() + }) + + it('keeps only the five most recent skills in order', () => { + vi.useFakeTimers() + + for (let i = 0; i < 6; i++) { + vi.setSystemTime(new Date(2026, 0, 1, 0, 0, i)) + mark(skill({ + name: `skill-${i}`, + path: `/repo/skill-${i}/SKILL.md`, + })) + } + + expect(getRecentSkills().map((entry) => entry.name)).toEqual([ + 'skill-5', + 'skill-4', + 'skill-3', + 'skill-2', + 'skill-1', + ]) + }) + + it('moves an existing skill to the top without duplicating it', () => { + vi.useFakeTimers() + const review = skill({ name: 'review', path: '/repo/review/SKILL.md' }) + const plan = skill({ name: 'plan', path: '/repo/plan/SKILL.md' }) + + vi.setSystemTime(new Date(2026, 0, 1, 0, 0, 1)) + mark(review) + vi.setSystemTime(new Date(2026, 0, 1, 0, 0, 2)) + mark(plan) + vi.setSystemTime(new Date(2026, 0, 1, 0, 0, 3)) + mark(review) + + expect(getRecentSkills().map((entry) => entry.name)).toEqual(['review', 'plan']) + }) + + it('keeps same-name skills distinct by path', () => { + mark(skill({ name: 'review', path: '/repo/review/SKILL.md', scope: 'repo' })) + mark(skill({ name: 'review', path: '/user/review/SKILL.md', scope: 'user' })) + + expect(getRecentSkills().map((entry) => `${entry.scope}:${entry.path}`)).toEqual([ + 'user:/user/review/SKILL.md', + 'repo:/repo/review/SKILL.md', + ]) + }) + + it('returns an empty list for corrupted storage', () => { + localStorage.setItem(RECENT_SKILLS_KEY, 'not-json') + + expect(getRecentSkills()).toEqual([]) + }) + + it('tolerates legacy name timestamp storage', () => { + localStorage.setItem(RECENT_SKILLS_KEY, JSON.stringify({ + review: 3, + plan: 4, + })) + + expect(getRecentSkills().map((entry) => entry.name)).toEqual(['plan', 'review']) + }) + + it('ignores storage write failures', () => { + const originalSetItem = localStorage.setItem + localStorage.setItem = vi.fn(() => { + throw new Error('storage unavailable') + }) + + expect(() => mark(skill({ name: 'review' }))).not.toThrow() + + localStorage.setItem = originalSetItem + }) +}) diff --git a/web/src/lib/recent-skills.ts b/web/src/lib/recent-skills.ts index 73c5296163..5a1b90c147 100644 --- a/web/src/lib/recent-skills.ts +++ b/web/src/lib/recent-skills.ts @@ -1,7 +1,19 @@ -const RECENT_SKILLS_KEY = 'hapi-recent-skills' -const MAX_RECENT_SKILLS = 200 +import type { SkillSummary } from '@/types/api' +import type { SkillSearchResult } from '@/lib/skill-search' -type RecentSkillsMap = Record +export const RECENT_SKILLS_KEY = 'hapi-recent-skills' +export const MAX_RECENT_SKILLS = 5 + +export type RecentSkillEntry = { + key: string + name: string + description: string + path: string + scope: SkillSummary['scope'] + pluginName?: string + pluginPath?: string + usedAt: number +} function safeParseJson(value: string): unknown { try { @@ -11,44 +23,106 @@ function safeParseJson(value: string): unknown { } } -export function getRecentSkills(): RecentSkillsMap { - if (typeof window === 'undefined') return {} +function cleanEntry(value: unknown): RecentSkillEntry | null { + if (!value || typeof value !== 'object') return null + const record = value as Record + if (typeof record.key !== 'string' || record.key.trim().length === 0) return null + if (typeof record.name !== 'string' || record.name.trim().length === 0) return null + if (typeof record.path !== 'string') return null + if (!['repo', 'user', 'plugin', 'admin'].includes(String(record.scope))) return null + if (typeof record.usedAt !== 'number' || !Number.isFinite(record.usedAt)) return null + + return { + key: record.key, + name: record.name, + description: typeof record.description === 'string' ? record.description : '', + path: record.path, + scope: record.scope as SkillSummary['scope'], + pluginName: typeof record.pluginName === 'string' ? record.pluginName : undefined, + pluginPath: typeof record.pluginPath === 'string' ? record.pluginPath : undefined, + usedAt: record.usedAt, + } +} + +function legacyMapToEntries(value: unknown): RecentSkillEntry[] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return [] + + return Object.entries(value as Record) + .flatMap(([name, usedAt]) => { + const cleanName = name.trim() + if (!cleanName) return [] + if (typeof usedAt !== 'number' || !Number.isFinite(usedAt)) return [] + return [{ + key: `legacy:$${cleanName}`, + name: cleanName, + description: '', + path: '', + scope: 'user' as const, + usedAt, + }] + }) + .sort((a, b) => b.usedAt - a.usedAt) + .slice(0, MAX_RECENT_SKILLS) +} + +export function skillToRecentEntry(suggestion: SkillSearchResult, usedAt = Date.now()): RecentSkillEntry { + return { + key: suggestion.key, + name: suggestion.skill.name, + description: suggestion.skill.description, + path: suggestion.skill.path, + scope: suggestion.skill.scope, + pluginName: suggestion.skill.pluginName, + pluginPath: suggestion.skill.pluginPath, + usedAt, + } +} + +export function recentEntryToSkill(entry: RecentSkillEntry): SkillSummary { + return { + name: entry.name, + description: entry.description, + path: entry.path, + scope: entry.scope, + pluginName: entry.pluginName, + pluginPath: entry.pluginPath, + } +} + +export function getRecentSkills(): RecentSkillEntry[] { + if (typeof window === 'undefined') return [] try { const raw = localStorage.getItem(RECENT_SKILLS_KEY) - if (!raw) return {} + if (!raw) return [] const parsed = safeParseJson(raw) - if (!parsed || typeof parsed !== 'object') return {} - - const record = parsed as Record - const result: RecentSkillsMap = {} - for (const [key, value] of Object.entries(record)) { - if (typeof key !== 'string' || key.trim().length === 0) continue - if (typeof value !== 'number' || !Number.isFinite(value)) continue - result[key] = value + if (!parsed) return [] + + if (Array.isArray(parsed)) { + return parsed + .map(cleanEntry) + .filter((entry): entry is RecentSkillEntry => Boolean(entry)) + .sort((a, b) => b.usedAt - a.usedAt) + .slice(0, MAX_RECENT_SKILLS) } - return result + + return legacyMapToEntries(parsed) } catch { - return {} + return [] } } -export function markSkillUsed(skillName: string): void { - const name = skillName.trim() - if (!name) return +export function markSkillUsed(suggestion: SkillSearchResult): void { if (typeof window === 'undefined') return try { - const recent = getRecentSkills() - recent[name] = Date.now() + const nextEntry = skillToRecentEntry(suggestion) + const next = [ + nextEntry, + ...getRecentSkills().filter((entry) => entry.key !== nextEntry.key) + ].slice(0, MAX_RECENT_SKILLS) - const entries = Object.entries(recent) - .sort((a, b) => b[1] - a[1]) - .slice(0, MAX_RECENT_SKILLS) - - const next: RecentSkillsMap = Object.fromEntries(entries) localStorage.setItem(RECENT_SKILLS_KEY, JSON.stringify(next)) } catch { // Ignore storage errors } } - diff --git a/web/src/lib/skill-search.test.ts b/web/src/lib/skill-search.test.ts new file mode 100644 index 0000000000..d1cd4c4d9a --- /dev/null +++ b/web/src/lib/skill-search.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import type { SkillSummary } from '@/types/api' +import { searchSkills } from './skill-search' + +function skill(overrides: Partial): SkillSummary { + return { + name: 'review', + description: 'Review code changes.', + path: '/repo/.agents/skills/review/SKILL.md', + scope: 'repo', + ...overrides, + } +} + +describe('skill search', () => { + it('returns all skills for an empty query in scanner order', () => { + const results = searchSkills([ + skill({ name: 'review', path: '/repo/review/SKILL.md' }), + skill({ name: 'compound-engineering:ce-plan', path: '/plugin/ce-plan/SKILL.md', scope: 'plugin' }), + ], '$') + + expect(results.map((result) => result.text)).toEqual([ + '$review', + '$compound-engineering:ce-plan', + ]) + }) + + it('matches plugin skills by local skill name', () => { + const results = searchSkills([ + skill({ name: 'review' }), + skill({ + name: 'compound-engineering:ce-brainstorm', + description: 'Explore requirements before planning implementation work.', + path: '/plugin/ce-brainstorm/SKILL.md', + scope: 'plugin', + pluginName: 'compound-engineering', + }), + skill({ + name: 'compound-engineering:ce-plan', + description: 'Create structured implementation plans.', + path: '/plugin/ce-plan/SKILL.md', + scope: 'plugin', + pluginName: 'compound-engineering', + }), + ], '$plan') + + expect(results.map((result) => result.text)).toEqual([ + '$compound-engineering:ce-plan', + '$compound-engineering:ce-brainstorm', + ]) + }) + + it('matches plugin skills by plugin name', () => { + const results = searchSkills([ + skill({ name: 'drawio', path: '/user/drawio/SKILL.md', scope: 'user' }), + skill({ + name: 'compound-engineering:ce-work', + description: 'Execute work.', + path: '/plugin/ce-work/SKILL.md', + scope: 'plugin', + pluginName: 'compound-engineering', + }), + ], 'compound') + + expect(results.map((result) => result.text)).toEqual(['$compound-engineering:ce-work']) + }) + + it('keeps duplicate names distinct by path', () => { + const results = searchSkills([ + skill({ name: 'review', path: '/repo/review/SKILL.md', scope: 'repo' }), + skill({ name: 'review', path: '/user/review/SKILL.md', scope: 'user' }), + ], '$review') + + expect(results.map((result) => result.key)).toEqual([ + '/repo/review/SKILL.md:$review', + '/user/review/SKILL.md:$review', + ]) + }) + + it('returns an empty array when no skills match', () => { + expect(searchSkills([ + skill({ name: 'review' }), + ], 'zzzz')).toEqual([]) + }) + + it('maps skills with empty descriptions to displayable results', () => { + const results = searchSkills([ + skill({ name: 'no-description', description: '', path: '/user/no-description/SKILL.md', scope: 'user' }), + ], 'no-description') + + expect(results[0]).toMatchObject({ + text: '$no-description', + description: 'user - /user/no-description/SKILL.md', + }) + }) +}) diff --git a/web/src/lib/skill-search.ts b/web/src/lib/skill-search.ts new file mode 100644 index 0000000000..6cb587ac54 --- /dev/null +++ b/web/src/lib/skill-search.ts @@ -0,0 +1,134 @@ +import type { SkillSummary } from '@/types/api' +import type { Suggestion } from '@/hooks/useActiveSuggestions' + +export interface SkillSearchResult { + key: string + text: string + label: string + description: string + skill: SkillSummary + source: SkillSummary['scope'] + path: string + scope: SkillSummary['scope'] +} + +function levenshteinDistance(a: string, b: string): number { + if (a.length === 0) return b.length + if (b.length === 0) return a.length + const matrix: number[][] = [] + for (let i = 0; i <= b.length; i++) matrix[i] = [i] + for (let j = 0; j <= a.length; j++) matrix[0][j] = j + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + matrix[i][j] = b[i - 1] === a[j - 1] + ? matrix[i - 1][j - 1] + : Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1) + } + } + return matrix[b.length][a.length] +} + +export function normalizeSkillQuery(queryText: string): string { + return queryText.startsWith('$') + ? queryText.slice(1).trim().toLowerCase() + : queryText.trim().toLowerCase() +} + +function localSkillName(skill: SkillSummary): string { + const separatorIndex = skill.name.lastIndexOf(':') + return separatorIndex >= 0 ? skill.name.slice(separatorIndex + 1) : skill.name +} + +function searchableValues(skill: SkillSummary): Array<{ value: string; weight: number }> { + return [ + { value: skill.name, weight: 0 }, + { value: localSkillName(skill), weight: 0 }, + { value: skill.pluginName, weight: 1 }, + { value: skill.scope, weight: 4 }, + { value: skill.description, weight: 10 }, + { value: skill.path, weight: 12 }, + ].filter((entry): entry is { value: string; weight: number } => Boolean(entry.value)) + .map((entry) => ({ ...entry, value: entry.value.toLowerCase() })) +} + +function scoreSkill(skill: SkillSummary, searchTerm: string): number { + if (!searchTerm) { + return 0 + } + + const maxDistance = Math.max(2, Math.floor(searchTerm.length / 2)) + let bestScore = Infinity + + for (const { value, weight } of searchableValues(skill)) { + let score: number + if (value === searchTerm) score = 0 + else if (value.startsWith(searchTerm)) score = 1 + else if (value.includes(searchTerm)) score = 2 + else { + const dist = levenshteinDistance(searchTerm, value) + score = dist <= maxDistance ? 3 + dist : Infinity + } + + bestScore = Math.min(bestScore, score + weight) + } + + return bestScore +} + +export function formatSkillDescription(skill: SkillSummary): string { + const source = skill.pluginName + ? `${skill.scope}:${skill.pluginName}` + : skill.scope + const details = `${source} - ${skill.path}` + if (!skill.description) { + return details + } + return `${skill.description} (${details})` +} + +export function skillToSearchResult(skill: SkillSummary): SkillSearchResult { + return { + key: `${skill.path}:$${skill.name}`, + text: `$${skill.name}`, + label: `$${skill.name}`, + description: formatSkillDescription(skill), + skill, + source: skill.scope, + path: skill.path, + scope: skill.scope, + } +} + +export function searchSkills(skills: readonly SkillSummary[], queryText: string): SkillSearchResult[] { + const searchTerm = normalizeSkillQuery(queryText) + if (!searchTerm) { + return skills.map(skillToSearchResult) + } + + return skills + .map((skill, index) => ({ + skill, + index, + score: scoreSkill(skill, searchTerm), + })) + .filter((item) => item.score < Infinity) + .sort((a, b) => ( + a.score - b.score + || a.skill.name.localeCompare(b.skill.name) + || a.skill.path.localeCompare(b.skill.path) + || a.index - b.index + )) + .map(({ skill }) => skillToSearchResult(skill)) +} + +export function skillSearchResultsToSuggestions(results: readonly SkillSearchResult[]): Suggestion[] { + return results.map((result) => ({ + key: result.key, + text: result.text, + label: result.label, + description: result.description, + source: result.source, + path: result.path, + scope: result.scope, + })) +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 58e68e5764..dffc20e1fd 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -26,7 +26,7 @@ import { useMachines } from '@/hooks/queries/useMachines' import { useSession } from '@/hooks/queries/useSession' import { useSessions } from '@/hooks/queries/useSessions' import { useSlashCommands } from '@/hooks/queries/useSlashCommands' -import { useSkills } from '@/hooks/queries/useSkills' +import { useSessionSkills } from '@/hooks/queries/useSkills' import { useSendMessage } from '@/hooks/mutations/useSendMessage' import { queryKeys } from '@/lib/query-keys' import { useToast } from '@/lib/toast-context' @@ -333,15 +333,20 @@ function SessionPage() { getSuggestions: getSlashSuggestions, } = useSlashCommands(api, sessionId, agentType) const { + skills, + refreshSkills, getSuggestions: getSkillSuggestions, - } = useSkills(api, sessionId) + } = useSessionSkills(api, sessionId, agentType === 'codex') const getAutocompleteSuggestions = useCallback(async (query: string) => { if (query.startsWith('$')) { + if (agentType !== 'codex') { + return [] + } return await getSkillSuggestions(query) } return await getSlashSuggestions(query) - }, [getSkillSuggestions, getSlashSuggestions]) + }, [agentType, getSkillSuggestions, getSlashSuggestions]) const refreshSelectedSession = useCallback(() => { void refetchSession() @@ -376,6 +381,8 @@ function SessionPage() { onAtBottomChange={setAtBottom} onRetryMessage={retryMessage} autocompleteSuggestions={getAutocompleteSuggestions} + availableSkills={skills} + refreshSkills={refreshSkills} availableSlashCommands={slashCommands} /> ) diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 2133a250b0..6ddedaab94 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -202,14 +202,14 @@ export type SlashCommandsResponse = { export type SkillSummary = { name: string - description?: string + description: string + path: string + scope: 'repo' | 'user' | 'plugin' | 'admin' + pluginName?: string + pluginPath?: string } -export type SkillsResponse = { - success: boolean - skills?: SkillSummary[] - error?: string -} +export type SkillsResponse = SkillSummary[] export type CodexModelSummary = { id: string diff --git a/web/src/utils/findActiveWord.test.ts b/web/src/utils/findActiveWord.test.ts new file mode 100644 index 0000000000..44d7bca2b5 --- /dev/null +++ b/web/src/utils/findActiveWord.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { findActiveWord } from './findActiveWord' + +describe('findActiveWord', () => { + it('finds a prefixed word at the current cursor token', () => { + expect(findActiveWord('run $rev', { start: 8, end: 8 }, ['$'])).toMatchObject({ + activeWord: '$rev', + offset: 4, + }) + }) + + it('does not keep an earlier dollar token active after a space', () => { + expect(findActiveWord('$review 检查代', { start: 12, end: 12 }, ['$'])).toBeUndefined() + }) + + it('does not keep an earlier slash token active after a space', () => { + expect(findActiveWord('/status continue', { start: 16, end: 16 }, ['/'])).toBeUndefined() + }) +}) diff --git a/web/src/utils/findActiveWord.ts b/web/src/utils/findActiveWord.ts index e79c523d5b..db73437d8a 100644 --- a/web/src/utils/findActiveWord.ts +++ b/web/src/utils/findActiveWord.ts @@ -29,26 +29,13 @@ function findActiveWordStart( prefixes: string[] ): number { let startIndex = selection.start - 1 - let spaceIndex = -1 - let foundPrefix = false - let prefixIndex = -1 while (startIndex >= 0) { const char = content.charAt(startIndex) // Check if we hit a space if (char === ' ') { - if (foundPrefix) { - // We found a prefix earlier, return its position - return prefixIndex - } - if (spaceIndex >= 0) { - // Multiple spaces, stop here - return spaceIndex + 1 - } else { - spaceIndex = startIndex - startIndex-- - } + return startIndex + 1 } // Check if this is a prefix character at word boundary else if ( @@ -57,8 +44,6 @@ function findActiveWordStart( ) { // For @ prefix, continue searching backwards to include the entire file path if (char === '@') { - foundPrefix = true - prefixIndex = startIndex // Return immediately for @ at word boundary return startIndex } else { @@ -67,9 +52,6 @@ function findActiveWordStart( } // Check if we hit a stop character else if (STOP_CHARACTERS.includes(char)) { - if (foundPrefix) { - return prefixIndex - } return startIndex + 1 } // Continue searching backwards @@ -79,10 +61,7 @@ function findActiveWordStart( } // Reached beginning of text - if (foundPrefix) { - return prefixIndex - } - return (spaceIndex >= 0 ? spaceIndex : startIndex) + 1 + return startIndex + 1 } function findActiveWordEnd(