Compare commits

...

3 Commits

Author SHA1 Message Date
2d997ecb6c web+coder: discover Claude's enabled commands + plugin skills; icon-split commands vs skills
claude is PTY (no ACP discovery), so claude-command-discovery.ts reads its enabled set from disk (user-global): ~/.claude/commands/*.md + every enabled plugin's skills/<name>/SKILL.md (kind=skill) and commands/*.md (kind=command), from ~/.claude/settings.json:enabledPlugins + installed_plugins.json install paths, frontmatter-parsed, bare names, deduped. The snapshot claude branch discovers these live (snapshot cache rate-limits the reads). The coder / menu now shows up to three icon'd groups: <agent> commands (Terminal), <agent> skills (Puzzle), BooCoder skills (Sparkles) via a new optional icon on SlashCommandGroup. AgentCommand gains a kind field in both coder + web copies (parity test enforces); mergeCommandsByName made generic to preserve it. Invocation unchanged (literal /name -> claude). Project-local plugins deferred. BooChat unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:21:32 +00:00
dc3859975d coder(providers): capture + persist opencode's live ACP commands (no dispatch needed)
The cold ACP probe captured available_commands but read probedCommands synchronously right after newSession, racing opencode's async available_commands_update notification -> captured nothing, only the static manifest showed. The probe now waits (poll <=3s + 300ms settle) for the notification. Captured commands persist to a new available_agents.commands column and are served (merged with the manifest) on the tier-2-skip path, so the agent's discovered commands survive once models are warm and show without a dispatch. Boot warms via the force:true startup snapshot. Caveat: relies on opencode emitting available_commands_update on session creation, not only post-prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:56:18 +00:00
23a33e893a web+coder: segmented per-agent slash menu (agent commands + skills) + cross-agent skill execution
Coder / menu now shows two groups: the active agent's commands first (manifest + live ACP available_commands), BooCoder skills second. SlashCommandPicker gains an opt-in groups prop (flat items path unchanged -> BooChat byte-identical, parity verified); ChatInput takes slashGroups; CoderPane builds the groups. Skills run under the selected agent: coder skill_invoke accepts a provider and, when external, injects the server-side skill body into a dispatched task instead of native inference. Also folds in the initial-chat skill fix (handleLandingSkill: create chat -> assign to pane -> invoke, same transition as a text send) that resolves the landing-page blank screen. BooChat slash menu + skill invocation unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:38:39 +00:00
16 changed files with 434 additions and 80 deletions

View File

@@ -2,6 +2,18 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.5.11-claude-skill-discovery — 2026-05-29
Surface Claude Code's real enabled commands + plugin skills in the coder slash menu, with icons separating commands from plugin skills. New `claude-command-discovery.ts` reads (user-global scope) `~/.claude/commands/*.md` plus every enabled plugin in `~/.claude/settings.json:enabledPlugins` — each plugin's user-scope install path contributes `skills/<name>/SKILL.md` (kind `skill`) and `commands/*.md` (kind `command`), parsed from frontmatter, bare names, deduped. The snapshot's claude branch discovers these **live** (claude is PTY, no ACP probe; the snapshot cache rate-limits the fs reads). The `/` menu now renders up to three icon'd groups: **`<agent> commands`** (Terminal), **`<agent> skills`** (Puzzle — claude's plugin skills / opencode is all commands), and **BooCoder skills** (Sparkles), via a new optional `icon` on `SlashCommandGroup`. `AgentCommand` gains a `kind` field, added identically to the coder and web copies (the `provider-types-parity` test enforces it); `mergeCommandsByName` is now generic so it preserves the tag. Invocation is unchanged — picking a claude command/skill sends `/name` to claude (PTY), which executes it. Project-local plugins + `<cwd>/.claude/commands` deferred. BooChat unaffected (flat skills). Smoke-test the claude skill slash-execution on the host.
## v2.5.10-opencode-live-commands — 2026-05-29
Surface opencode's real (live ACP) command set in the coder slash menu without needing a dispatch. Two fixes: (1) the cold ACP probe (`acp-probe.ts`) captured `available_commands` but read `probedCommands` synchronously right after `newSession` — racing opencode's async `available_commands_update` notification, so it captured **zero** and only the 7-item static manifest showed. The probe now waits briefly (poll up to 3s for the first batch + a 300ms settle, capped under the 30s probe timeout) so the commands are actually captured. (2) Captured commands are persisted to a new `available_agents.commands` JSONB column and served (merged with the manifest) on the tier-2-probe-skip path, so the agent's discovered commands survive once the model list is warm and show without a dispatch. Boot warms this via the `force: true` startup snapshot. apps/coder only (probe + schema + snapshot). Caveat: depends on opencode emitting `available_commands_update` on session creation rather than only after a prompt — to be confirmed on the host. Claude (PTY) disk/plugin discovery deferred.
## v2.5.9-agent-slash-commands — 2026-05-29
Segmented per-agent slash menu in the coder pane, plus cross-agent skills. The `/` menu now shows two labeled groups — **the active agent's commands first** (opencode/claude/qwen manifest + live ACP `available_commands`), **BooCoder skills second** — instead of always showing BooCoder's skills regardless of provider. `SlashCommandPicker` gains an opt-in `groups` prop (the flat `items` path is unchanged, so **BooChat's menu is byte-identical** — parity verified: no BooChat caller passes the grouped prop, and the skills lookup / invocation routing are untouched); `ChatInput` takes `slashGroups`; `CoderPane` builds the groups from the selected provider's commands + skills. Skills now **run under the selected agent**: the coder `skill_invoke` route accepts a `provider` and, when external, injects the server-side skill body into a dispatched task (instead of native inference) — so a skill like brainstorming executes through opencode/claude with the body kept server-side, mirroring the messages-route external dispatch. Also folds in the earlier initial-chat fix: invoking a skill on the landing chat now runs the same create-chat → assign-to-pane → invoke transition as a text send (`handleLandingSkill`) rather than invoking invisibly without a pane transition (the blank-screen repro). Web tsc + coder build clean.
## v2.5.8-mobile-composer-row — 2026-05-29 ## v2.5.8-mobile-composer-row — 2026-05-29
Mobile fix for the `AgentComposerBar`: the refresh button was wrapping to a second line. Root cause was layout order, not width — the status dot carried `ml-auto` (pinned to the far-right edge) and the refresh button followed it in DOM order, so it overflowed and wrapped. The dot + refresh are now one right-aligned (`ml-auto`) unit, keeping the refresh on the top line. Additionally, `CompactPicker` gained an `iconOnly` option and the Mode (permission) picker now renders icon-only on mobile (shield + chevron, no "Bypass"/"Plan" text label; `aria-label`/`title` and the tap-to-open list still convey the value) to free row width. Desktop is unchanged (full labels). Web-only change. Mobile fix for the `AgentComposerBar`: the refresh button was wrapping to a second line. Root cause was layout order, not width — the status dot carried `ml-auto` (pinned to the far-right edge) and the refresh button followed it in DOM order, so it overflowed and wrapped. The dot + refresh are now one right-aligned (`ml-auto`) unit, keeping the refresh on the top line. Additionally, `CompactPicker` gained an `iconOnly` option and the Mode (permission) picker now renders icon-only on mobile (shield + chevron, no "Bypass"/"Plan" text label; `aria-label`/`title` and the tap-to-open list still convey the value) to free row width. Desktop is unchanged (full labels). Web-only change.

View File

@@ -16,6 +16,12 @@ const SkillInvokeBody = z.object({
pane_id: z.string().min(1).max(200), pane_id: z.string().min(1).max(200),
skill_name: z.string().min(1), skill_name: z.string().min(1),
user_message: z.string().max(64_000).nullable().optional(), user_message: z.string().max(64_000).nullable().optional(),
// v2.5.9: when set to an external provider, the skill runs UNDER that agent —
// its body is injected into a dispatched task instead of native inference.
provider: z.string().max(100).optional(),
model: z.string().max(200).optional(),
mode_id: z.string().max(200).optional(),
thinking_option_id: z.string().max(200).optional(),
}); });
interface InferenceApi { interface InferenceApi {
@@ -39,9 +45,9 @@ export function registerSkillRoutes(
} }
const sessionId = req.params.sessionId; const sessionId = req.params.sessionId;
const { pane_id, skill_name } = parsed.data; const { pane_id, skill_name, provider, model, mode_id, thinking_option_id } = parsed.data;
const sessionRows = await sql<{ id: string }[]>` const sessionRows = await sql<{ id: string; project_id: string }[]>`
SELECT id FROM sessions WHERE id = ${sessionId} SELECT id, project_id FROM sessions WHERE id = ${sessionId}
`; `;
if (sessionRows.length === 0) { if (sessionRows.length === 0) {
reply.code(404); reply.code(404);
@@ -69,6 +75,31 @@ export function registerSkillRoutes(
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` }; return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
} }
// v2.5.9: external agent → run the skill UNDER that agent. The skill body
// stays server-side (like the native path's tool message) and is injected
// into a dispatched task; the agent receives the skill instructions + the
// user's text. Mirrors the messages-route external-provider dispatch.
if (provider && provider !== 'boocode') {
const [userMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${userText}, 'complete', clock_timestamp())
RETURNING id
`;
broker.publishFrame(sessionId, { type: 'message_started', message_id: userMsg!.id, chat_id: chatId, role: 'user' } as WsFrame);
broker.publishFrame(sessionId, { type: 'delta', message_id: userMsg!.id, chat_id: chatId, content: userText } as WsFrame);
broker.publishFrame(sessionId, { type: 'message_complete', message_id: userMsg!.id, chat_id: chatId } as WsFrame);
const taskInput = `${body}\n\n---\n\n${userText}`;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
RETURNING id, state
`;
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
reply.code(202);
return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true };
}
const { result, toolCall } = await runSkillInvokeTransaction(sql, { const { result, toolCall } = await runSkillInvokeTransaction(sql, {
sessionId, sessionId,
chatId, chatId,

View File

@@ -66,6 +66,10 @@ CREATE OR REPLACE VIEW human_inbox AS
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb; ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT; ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty'; ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
-- v2.5.10: persisted ACP available_commands (captured during the cold probe), so
-- an agent's live command set survives the tier-2 probe skip and shows without a
-- dispatch.
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS commands JSONB DEFAULT '[]'::jsonb;
-- v2.2.0: Paseo-style session config on tasks. -- v2.2.0: Paseo-style session config on tasks.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;

View File

@@ -130,6 +130,17 @@ export async function probeAcpProvider(
}); });
const session = await connection.newSession({ cwd, mcpServers: [] }); const session = await connection.newSession({ cwd, mcpServers: [] });
// available_commands_update is an async session notification opencode sends
// shortly AFTER newSession resolves — reading probedCommands synchronously
// here races it and captures nothing. Wait briefly for the first batch, then
// a short settle for any stragglers (capped well under PROBE_TIMEOUT_MS).
const deadline = Date.now() + 3_000;
while (probedCommands.length === 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 150));
}
if (probedCommands.length > 0) {
await new Promise((r) => setTimeout(r, 300));
}
const result = parseSessionResponse(session, agent); const result = parseSessionResponse(session, agent);
result.commands = probedCommands; result.commands = probedCommands;
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {}); await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});

View File

@@ -0,0 +1,108 @@
/**
* v2.5.11: discover Claude Code's real, enabled commands + plugin skills from
* disk so the coder slash menu shows them (claude is PTY — no ACP discovery).
*
* Scope (v1): user-global only — `~/.claude/commands/*.md` plus the enabled
* plugins listed in `~/.claude/settings.json:enabledPlugins` (user-scope install
* paths from `~/.claude/plugins/.../installed_plugins.json`). Project-local
* plugins and `<cwd>/.claude/commands` are deferred. Names are bare.
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { AgentCommand } from './provider-types.js';
/** Minimal frontmatter reader — single-line `key: value` between `---` fences. */
function frontmatterField(content: string, field: string): string | undefined {
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!block?.[1]) return undefined;
const m = block[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
return m?.[1]?.trim().replace(/^["']|["']$/g, '') || undefined;
}
function readCommandDir(dir: string): AgentCommand[] {
if (!existsSync(dir)) return [];
let files: string[];
try {
files = readdirSync(dir);
} catch {
return [];
}
const out: AgentCommand[] = [];
for (const f of files) {
if (!f.endsWith('.md')) continue;
let description: string | undefined;
try {
description = frontmatterField(readFileSync(join(dir, f), 'utf8'), 'description');
} catch {
/* unreadable — still list the command by name */
}
out.push({ name: f.slice(0, -3), kind: 'command', ...(description ? { description } : {}) });
}
return out;
}
function readSkillDir(dir: string): AgentCommand[] {
if (!existsSync(dir)) return [];
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return [];
}
const out: AgentCommand[] = [];
for (const sub of entries) {
const skillMd = join(dir, sub, 'SKILL.md');
if (!existsSync(skillMd)) continue;
let content: string;
try {
content = readFileSync(skillMd, 'utf8');
} catch {
continue;
}
out.push({
name: frontmatterField(content, 'name') ?? sub,
kind: 'skill',
...(() => {
const d = frontmatterField(content, 'description');
return d ? { description: d } : {};
})(),
});
}
return out;
}
export function discoverClaudeCommands(): AgentCommand[] {
const root = join(homedir(), '.claude');
const out: AgentCommand[] = [];
// User custom commands.
out.push(...readCommandDir(join(root, 'commands')));
// Enabled plugins (user-scope installs).
try {
const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as {
enabledPlugins?: Record<string, boolean>;
};
const installed = JSON.parse(
readFileSync(join(root, 'plugins', 'installed_plugins.json'), 'utf8'),
) as { plugins?: Record<string, Array<{ scope?: string; installPath?: string }>> };
const enabled = settings.enabledPlugins ?? {};
const plugins = installed.plugins ?? {};
for (const [key, on] of Object.entries(enabled)) {
if (!on) continue;
const installs = plugins[key] ?? [];
const installPath = (installs.find((i) => i.scope === 'user') ?? installs[0])?.installPath;
if (!installPath || !existsSync(installPath)) continue;
out.push(...readSkillDir(join(installPath, 'skills')));
out.push(...readCommandDir(join(installPath, 'commands')));
}
} catch {
/* missing/unreadable plugin config → user commands only */
}
// Dedupe by name (first wins).
const seen = new Set<string>();
return out.filter((c) => (seen.has(c.name) ? false : (seen.add(c.name), true)));
}

View File

@@ -11,17 +11,19 @@ import {
PROVIDER_MANIFEST, PROVIDER_MANIFEST,
} from './provider-manifest.js'; } from './provider-manifest.js';
import { probeAcpProvider } from './acp-probe.js'; import { probeAcpProvider } from './acp-probe.js';
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js'; import type { ProviderModel, ProviderSnapshotEntry, AgentCommand } from './provider-types.js';
import { getManifestCommands, mergeCommands } from './provider-commands.js'; import { getManifestCommands, mergeCommands } from './provider-commands.js';
import { readQwenSettingsModels } from './qwen-settings.js'; import { readQwenSettingsModels } from './qwen-settings.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js'; import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
import { isCommandAvailable } from './command-availability.js'; import { isCommandAvailable } from './command-availability.js';
import { discoverClaudeCommands } from './claude-command-discovery.js';
interface AgentRow { interface AgentRow {
name: string; name: string;
install_path: string | null; install_path: string | null;
supports_acp: boolean; supports_acp: boolean;
models: ProviderModel[] | null; models: ProviderModel[] | null;
commands: AgentCommand[] | null;
label: string | null; label: string | null;
transport: string | null; transport: string | null;
last_probed_at: string | Date | null; last_probed_at: string | Date | null;
@@ -82,6 +84,9 @@ async function buildProviderEntry(
const fallbackModes = getManifestModes(name); const fallbackModes = getManifestModes(name);
const defaultModeId = getManifestDefaultModeId(name); const defaultModeId = getManifestDefaultModeId(name);
const manifestCommands = getManifestCommands(name); const manifestCommands = getManifestCommands(name);
// Manifest + persisted live ACP commands (captured on a prior cold probe), so
// the agent's discovered commands show even when the tier-2 probe is skipped.
const dbCommands = mergeCommands(manifestCommands, agentRow?.commands ?? []);
const label = agentRow?.label ?? resolved.configLabel ?? resolved.label; const label = agentRow?.label ?? resolved.configLabel ?? resolved.label;
const descr = resolved.configDescription ? { description: resolved.configDescription } : {}; const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
@@ -145,10 +150,12 @@ async function buildProviderEntry(
// claude: static models + thinking options, no ACP probe (unchanged from v2.2). // claude: static models + thinking options, no ACP probe (unchanged from v2.2).
if (name === 'claude') { if (name === 'claude') {
// claude is PTY (no ACP discovery) — read its enabled commands + plugin
// skills from disk live (the snapshot cache rate-limits the fs reads).
return { return {
name, label, transport, status: 'ready', enabled: true, installed: true, name, label, transport, status: 'ready', enabled: true, installed: true,
models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId, models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
commands: manifestCommands, commands: mergeCommands(manifestCommands, discoverClaudeCommands()),
}; };
} }
@@ -175,7 +182,7 @@ async function buildProviderEntry(
} }
return { return {
name, label, transport, status: 'ready', enabled: true, installed: true, name, label, transport, status: 'ready', enabled: true, installed: true,
models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: manifestCommands, models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: dbCommands,
}; };
} }
@@ -213,7 +220,7 @@ async function buildProviderEntry(
} }
return { return {
name, label, transport, status: 'ready', enabled: true, installed: true, name, label, transport, status: 'ready', enabled: true, installed: true,
models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: manifestCommands, models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: dbCommands,
}; };
} }
@@ -242,7 +249,7 @@ export async function getProviderSnapshot(
const build = async (): Promise<ProviderSnapshotEntry[]> => { const build = async (): Promise<ProviderSnapshotEntry[]> => {
const llamaModels = await fetchLlamaSwapModels(config); const llamaModels = await fetchLlamaSwapModels(config);
const agents = await sql<AgentRow[]>` const agents = await sql<AgentRow[]>`
SELECT name, install_path, supports_acp, models, label, transport, last_probed_at FROM available_agents SELECT name, install_path, supports_acp, models, commands, label, transport, last_probed_at FROM available_agents
`; `;
const agentMap = new Map(agents.map((a) => [a.name, a])); const agentMap = new Map(agents.map((a) => [a.name, a]));
const ttlMs = config.PROVIDER_PROBE_TTL_MS; const ttlMs = config.PROVIDER_PROBE_TTL_MS;
@@ -284,16 +291,34 @@ export async function persistProbedModels(
): Promise<void> { ): Promise<void> {
let count = 0; let count = 0;
for (const entry of entries) { for (const entry of entries) {
if (entry.name === 'boocode' || entry.models.length === 0) continue; if (entry.name === 'boocode') continue;
const flatModels = entry.models.map(({ id, label }) => ({ id, label })); let persisted = false;
await sql` if (entry.models.length > 0) {
UPDATE available_agents const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp() await sql`
WHERE name = ${entry.name} UPDATE available_agents
`; SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
count++; WHERE name = ${entry.name}
`;
persisted = true;
}
// Persist captured ACP commands so they survive the tier-2 probe skip and
// show without a dispatch. Only when non-empty — never clobber a prior set.
if (entry.commands.length > 0) {
const flatCommands = entry.commands.map((c) => ({
name: c.name,
...(c.description ? { description: c.description } : {}),
}));
await sql`
UPDATE available_agents
SET commands = ${sql.json(flatCommands as never)}, last_probed_at = clock_timestamp()
WHERE name = ${entry.name}
`;
persisted = true;
}
if (persisted) count++;
} }
if (count > 0) { if (count > 0) {
log.info({ count }, 'provider-snapshot: persisted models to available_agents'); log.info({ count }, 'provider-snapshot: persisted models/commands to available_agents');
} }
} }

View File

@@ -30,6 +30,9 @@ export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'erro
export interface AgentCommand { export interface AgentCommand {
name: string; name: string;
description?: string; description?: string;
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
// Drives the icon split in the coder slash menu. Undefined → command.
kind?: 'command' | 'skill';
} }
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is // KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is

View File

@@ -332,18 +332,32 @@ export const api = {
request<CoderMessageWire[]>( request<CoderMessageWire[]>(
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`, `/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
), ),
skillInvoke: (sessionId: string, paneId: string, skillName: string, userMessage: string | null) => skillInvoke: (
sessionId: string,
paneId: string,
skillName: string,
userMessage: string | null,
// v2.5.9: when the active provider is external, the skill runs under that
// agent (body injected into a dispatched task) → response carries task_id.
config?: { provider?: string; model?: string; mode_id?: string; thinking_option_id?: string },
) =>
request<{ request<{
user_message_id: string; user_message_id: string;
assistant_message_id: string; assistant_message_id?: string;
synth_assistant_id: string; synth_assistant_id?: string;
tool_message_id: string; tool_message_id?: string;
task_id?: string;
dispatched?: boolean;
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, { }>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
pane_id: paneId, pane_id: paneId,
skill_name: skillName, skill_name: skillName,
user_message: userMessage, user_message: userMessage,
...(config?.provider ? { provider: config.provider } : {}),
...(config?.model ? { model: config.model } : {}),
...(config?.mode_id ? { mode_id: config.mode_id } : {}),
...(config?.thinking_option_id ? { thinking_option_id: config.thinking_option_id } : {}),
}), }),
}), }),
// Queue a new-file create from the RightRail browser → BooCoder // Queue a new-file create from the RightRail browser → BooCoder

View File

@@ -273,6 +273,9 @@ export interface PermissionPrompt {
export interface AgentCommand { export interface AgentCommand {
name: string; name: string;
description?: string; description?: string;
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
// Drives the icon split in the coder slash menu. Undefined → command.
kind?: 'command' | 'skill';
} }
export interface CoderSendMessageBody { export interface CoderSendMessageBody {

View File

@@ -24,7 +24,7 @@ import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker'; import { AgentPicker } from '@/components/AgentPicker';
import { AgentCommandsHint } from '@/components/AgentCommandsHint'; import { AgentCommandsHint } from '@/components/AgentCommandsHint';
import { ContextBar } from '@/components/ContextBar'; import { ContextBar } from '@/components/ContextBar';
import { SlashCommandPicker } from '@/components/SlashCommandPicker'; import { SlashCommandPicker, type SlashCommandGroup } from '@/components/SlashCommandPicker';
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command'; import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { Message } from '@/api/types'; import type { Message } from '@/api/types';
@@ -56,6 +56,13 @@ interface Props {
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop // empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
// disables slash-command dispatch (input is sent as literal text). // disables slash-command dispatch (input is sent as literal text).
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>; onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
// v2.5.9: segmented slash-command DISPLAY source for the picker + hint. When
// provided (e.g. CoderPane passing [agent commands, skills]), these labeled
// groups are shown instead of the BooChat skills. Invocation routing still
// uses the skills lookup — names not in skills (opencode's /help etc.) fall
// through and are sent to the agent as literal text. Omitted → BooChat skills
// (flat, unchanged — parity).
slashGroups?: SlashCommandGroup[];
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input // v1.10.4: send-to-chat reverse path. When chatId is provided, this input
// registers in chatInputsRegistry so the terminal floating menu can list // registers in chatInputsRegistry so the terminal floating menu can list
// it, and subscribes to sendToChat events scoped to this chatId. Receiving // it, and subscribes to sendToChat events scoped to this chatId. Receiving
@@ -71,7 +78,7 @@ interface Props {
modelContextLimit?: number | null; modelContextLimit?: number | null;
} }
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, chatId, chatLabel, messages, modelContextLimit }: Props) { export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
const { isMobile } = useViewport(); const { isMobile } = useViewport();
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -100,6 +107,15 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
for (const s of skills) m.set(s.name, true); for (const s of skills) m.set(s.name, true);
return m; return m;
}, [skills]); }, [skills]);
// Flat display source for the hint (and the picker's no-groups fallback):
// caller-provided groups flattened, else the BooChat skills.
const slashItems = useMemo(
() =>
slashGroups
? slashGroups.flatMap((g) => g.items)
: skills.map((s) => ({ name: s.name, description: s.description })),
[slashGroups, skills],
);
const [fileIndex, setFileIndex] = useState<string[] | null>(null); const [fileIndex, setFileIndex] = useState<string[] | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -561,8 +577,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
))} ))}
</div> </div>
)} )}
{skills.length > 0 && ( {slashItems.length > 0 && (
<AgentCommandsHint commands={skills.map((s) => ({ name: s.name, description: s.description }))} /> <AgentCommandsHint commands={slashItems} />
)} )}
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1 {/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1
inlines ContextBar in the same row so the bar lives next to the inlines ContextBar in the same row so the bar lives next to the
@@ -661,11 +677,12 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
{slashState && ( {slashState && (
<SlashCommandPicker <SlashCommandPicker
query={slashState.query} query={slashState.query}
items={skills} items={slashItems}
groups={slashGroups}
inputRef={textareaRef} inputRef={textareaRef}
onSelect={handleSlashSelect} onSelect={handleSlashSelect}
onClose={() => setSlashState(null)} onClose={() => setSlashState(null)}
emptyLabel="No skills available" emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
/> />
)} )}
</div> </div>

View File

@@ -1,6 +1,5 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api/client';
import { ChatInput } from '@/components/ChatInput'; import { ChatInput } from '@/components/ChatInput';
interface Props { interface Props {
@@ -9,6 +8,10 @@ interface Props {
agentId?: string | null; agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>; onAgentChange?: (agentId: string | null) => void | Promise<void>;
onSend: (content: string) => void; onSend: (content: string) => void;
// Slash-command (skill) send from the landing page. The parent creates the
// chat, assigns it to the pane (so it transitions to ChatPane), and invokes
// the skill — same transition the text send uses. See useSessionChats.
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
createChat: () => Promise<{ id: string }>; createChat: () => Promise<{ id: string }>;
} }
@@ -18,6 +21,7 @@ export function SessionLandingPage({
agentId, agentId,
onAgentChange, onAgentChange,
onSend, onSend,
onSkillInvoke,
createChat, createChat,
}: Props) { }: Props) {
const [chatId, setChatId] = useState<string | null>(null); const [chatId, setChatId] = useState<string | null>(null);
@@ -45,14 +49,13 @@ export function SessionLandingPage({
} }
}, [ensureChat, onSend]); }, [ensureChat, onSend]);
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => { // Route to the parent, which creates the chat, assigns it to the pane (so the
try { // pane transitions to ChatPane and subscribes to the stream), then invokes the
const cid = await ensureChat(); // skill — mirroring the text-send transition. Doing the skill invoke locally
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null); // (without the pane assignment) left the landing pane stuck/blank.
} catch (err) { const handleSlashCommand = useCallback((skillName: string, userMessage: string) => {
toast.error(err instanceof Error ? err.message : `/${skillName} failed`); onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
} }, [onSkillInvoke]);
}, [ensureChat]);
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, RefObject } from 'react'; import type { CSSProperties, ReactNode, RefObject } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -8,9 +8,19 @@ export interface SlashCommandItem {
description?: string; description?: string;
} }
export interface SlashCommandGroup {
label: string;
items: SlashCommandItem[];
icon?: ReactNode;
}
interface Props { interface Props {
query: string; query: string;
items: SlashCommandItem[]; items: SlashCommandItem[];
// Optional segmented rendering. When provided, items are shown under labeled
// group headers (in order). `items` is ignored. BooChat passes only `items`
// (flat) so its menu is unchanged — grouping is opt-in.
groups?: SlashCommandGroup[];
inputRef: RefObject<HTMLElement | null>; inputRef: RefObject<HTMLElement | null>;
onSelect: (name: string) => void; onSelect: (name: string) => void;
onClose: () => void; onClose: () => void;
@@ -28,6 +38,7 @@ function filterByPrefix(items: SlashCommandItem[], query: string): SlashCommandI
export function SlashCommandPicker({ export function SlashCommandPicker({
query, query,
items, items,
groups,
inputRef, inputRef,
onSelect, onSelect,
onClose, onClose,
@@ -35,7 +46,21 @@ export function SlashCommandPicker({
}: Props) { }: Props) {
const [highlightIndex, setHighlightIndex] = useState(0); const [highlightIndex, setHighlightIndex] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => filterByPrefix(items, query), [items, query]); // When grouped, filter each group and drop empties; otherwise the flat list.
const filteredGroups = useMemo(
() =>
groups
? groups
.map((g) => ({ label: g.label, icon: g.icon, items: filterByPrefix(g.items, query) }))
.filter((g) => g.items.length > 0)
: null,
[groups, query],
);
// Flat list drives keyboard nav + Enter selection across all groups.
const filtered = useMemo(
() => (filteredGroups ? filteredGroups.flatMap((g) => g.items) : filterByPrefix(items, query)),
[filteredGroups, items, query],
);
const [rect, setRect] = useState<DOMRect | null>( const [rect, setRect] = useState<DOMRect | null>(
() => inputRef.current?.getBoundingClientRect() ?? null, () => inputRef.current?.getBoundingClientRect() ?? null,
@@ -130,6 +155,36 @@ export function SlashCommandPicker({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [rect, vvTick]); }, [rect, vvTick]);
const renderItem = (item: SlashCommandItem, i: number) => (
<div
key={`${i}-${item.name}`}
role="option"
aria-selected={i === highlightIndex}
data-highlighted={i === highlightIndex}
className={cn(
'w-full text-left px-2.5 py-2 cursor-pointer block',
i === highlightIndex && 'bg-muted',
)}
onMouseEnter={() => setHighlightIndex(i)}
onClick={() => onSelect(item.name)}
>
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
{item.description && (
<div
className="text-xs text-muted-foreground overflow-hidden"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{item.description}
</div>
)}
</div>
);
let runningIndex = -1;
const popover = filtered.length === 0 ? ( const popover = filtered.length === 0 ? (
<div <div
ref={popoverRef} ref={popoverRef}
@@ -146,34 +201,17 @@ export function SlashCommandPicker({
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto overscroll-contain touch-pan-y" className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto overscroll-contain touch-pan-y"
style={style} style={style}
> >
{filtered.map((item, i) => ( {filteredGroups
<div ? filteredGroups.map((g) => (
key={item.name} <div key={g.label}>
role="option" <div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70 flex items-center gap-1.5">
aria-selected={i === highlightIndex} {g.icon}
data-highlighted={i === highlightIndex} {g.label}
className={cn( </div>
'w-full text-left px-2.5 py-2 cursor-pointer block', {g.items.map((item) => renderItem(item, (runningIndex += 1)))}
i === highlightIndex && 'bg-muted',
)}
onMouseEnter={() => setHighlightIndex(i)}
onClick={() => onSelect(item.name)}
>
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
{item.description && (
<div
className="text-xs text-muted-foreground overflow-hidden"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{item.description}
</div> </div>
)} ))
</div> : filtered.map((item, i) => renderItem(item, i))}
))}
</div> </div>
); );

View File

@@ -82,6 +82,7 @@ export function Workspace({
deleteChat, deleteChat,
renameChat, renameChat,
handleLandingSend, handleLandingSend,
handleLandingSkill,
} = chatsHook; } = chatsHook;
const { isMobile } = useViewport(); const { isMobile } = useViewport();
@@ -387,6 +388,7 @@ export function Workspace({
onAgentChange={onAgentChange} onAgentChange={onAgentChange}
createChat={() => api.chats.create(sessionId)} createChat={() => api.chats.create(sessionId)}
onSend={(content) => void handleLandingSend(idx, content)} onSend={(content) => void handleLandingSend(idx, content)}
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
/> />
)} )}
</div> </div>

View File

@@ -4,10 +4,11 @@
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502). // WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Code, Check, X, RefreshCw } from 'lucide-react'; import { Code, Check, X, RefreshCw, Terminal, Puzzle, Sparkles } from 'lucide-react';
import { AgentComposerBar } from '@/components/AgentComposerBar'; import { AgentComposerBar } from '@/components/AgentComposerBar';
import { PermissionCard } from '@/components/PermissionCard'; import { PermissionCard } from '@/components/PermissionCard';
import { ChatInput } from '@/components/ChatInput'; import { ChatInput } from '@/components/ChatInput';
import type { SlashCommandGroup } from '@/components/SlashCommandPicker';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types'; import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
import { useSkills } from '@/hooks/useSkills'; import { useSkills } from '@/hooks/useSkills';
@@ -510,6 +511,50 @@ export function CoderPane({
[displayedCommands], [displayedCommands],
); );
// v2.5.9: segmented slash menu — the active agent's commands first, then
// BooCoder skills. boocode has no separate "commands" group (it IS native),
// so it shows only Skills. Empty groups are dropped.
const agentCommands = useMemo(
() =>
agentConfig.provider === 'boocode'
? []
: mergeCommandsByName(providerCommands, liveTaskCommands),
[agentConfig.provider, providerCommands, liveTaskCommands],
);
const skillItems = useMemo(
() => skills.map((s) => ({ name: s.name, description: s.description })),
[skills],
);
const slashGroups = useMemo(() => {
const groups: SlashCommandGroup[] = [];
// Split the active agent's set: native/CLI commands vs plugin skills, each
// with its own icon. BooCoder skills always come last.
const agentCmds = agentCommands.filter((c) => c.kind !== 'skill');
const agentSkills = agentCommands.filter((c) => c.kind === 'skill');
if (agentCmds.length > 0) {
groups.push({
label: `${agentConfig.provider} commands`,
items: agentCmds,
icon: <Terminal className="size-3 shrink-0" />,
});
}
if (agentSkills.length > 0) {
groups.push({
label: `${agentConfig.provider} skills`,
items: agentSkills,
icon: <Puzzle className="size-3 shrink-0" />,
});
}
if (skillItems.length > 0) {
groups.push({
label: 'BooCoder skills',
items: skillItems,
icon: <Sparkles className="size-3 shrink-0" />,
});
}
return groups;
}, [agentCommands, skillItems, agentConfig.provider]);
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, { const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
onConnectedChange, onConnectedChange,
onPermissionRequested: (prompt) => { onPermissionRequested: (prompt) => {
@@ -736,19 +781,35 @@ export function CoderPane({
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => { const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
if (!chatId) return; if (!chatId) return;
if (agentConfig.provider === 'boocode' && skillsByName.has(skillName)) { // Only BooCoder skills route here; an agent's own commands (not skills) fall
setSending(true); // through to a literal send in ChatInput. Skills run under the active
setPermissionPrompt(null); // provider: boocode → native inference; external → body injected into a task.
setLiveTaskCommands([]); if (!skillsByName.has(skillName)) return;
try { setSending(true);
await api.coder.skillInvoke(sessionId, paneId, skillName, userMessage.length > 0 ? userMessage : null); setPermissionPrompt(null);
} catch (err) { setLiveTaskCommands([]);
toast.error(err instanceof Error ? err.message : 'skill invocation failed'); try {
} finally { const data = await api.coder.skillInvoke(
setSending(false); sessionId,
} paneId,
skillName,
userMessage.length > 0 ? userMessage : null,
agentConfig.provider !== 'boocode'
? {
provider: agentConfig.provider,
model: agentConfig.model || undefined,
mode_id: agentConfig.modeId ?? undefined,
thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
}
: undefined,
);
if (data.task_id) setActiveTaskId(data.task_id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
} finally {
setSending(false);
} }
}, [chatId, sessionId, paneId, agentConfig.provider, skillsByName]); }, [chatId, sessionId, paneId, agentConfig, skillsByName]);
return ( return (
<div className="flex flex-col h-full bg-background"> <div className="flex flex-col h-full bg-background">
@@ -810,6 +871,7 @@ export function CoderPane({
projectId={projectPath ?? ''} projectId={projectPath ?? ''}
onSend={handleChatInputSend} onSend={handleChatInputSend}
onSlashCommand={handleChatInputSlash} onSlashCommand={handleChatInputSlash}
slashGroups={slashGroups}
chatId={chatId ?? undefined} chatId={chatId ?? undefined}
chatLabel="BooCode" chatLabel="BooCode"
messages={messages as unknown as import('@/api/types').Message[]} messages={messages as unknown as import('@/api/types').Message[]}

View File

@@ -24,6 +24,7 @@ export interface UseSessionChatsResult {
deleteChat: (chatId: string) => Promise<void>; deleteChat: (chatId: string) => Promise<void>;
renameChat: (chatId: string, name: string) => Promise<void>; renameChat: (chatId: string, name: string) => Promise<void>;
handleLandingSend: (paneIdx: number, content: string) => Promise<void>; handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
handleLandingSkill: (paneIdx: number, skillName: string, userMessage: string | null) => Promise<void>;
} }
export function useSessionChats( export function useSessionChats(
@@ -166,6 +167,25 @@ export function useSessionChats(
} }
}, [sessionId]); }, [sessionId]);
// Slash-command equivalent of handleLandingSend: the initial (landing) chat
// must create the chat AND assign it to the pane (openChatInPane) before
// invoking the skill, so the pane transitions to ChatPane and subscribes to
// the chat's stream. Skipping the assignment left the pane stuck on the
// landing page while the skill ran invisibly (and could blank the pane).
const handleLandingSkill = useCallback(
async (paneIdx: number, skillName: string, userMessage: string | null) => {
try {
const chat = await api.chats.create(sessionId);
setChats((prev) => (prev.some((c) => c.id === chat.id) ? prev : [chat, ...prev]));
openChatInPaneRef.current(paneIdx, chat.id);
await api.chats.skillInvoke(chat.id, skillName, userMessage);
} catch (err) {
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
}
},
[sessionId],
);
return { return {
chats, chats,
setChats, setChats,
@@ -175,5 +195,6 @@ export function useSessionChats(
deleteChat, deleteChat,
renameChat, renameChat,
handleLandingSend, handleLandingSend,
handleLandingSkill,
}; };
} }

View File

@@ -18,8 +18,8 @@ export function parseSlashInput(text: string): { cmdName: string; args: string }
return { cmdName: match[1]!, args: (match[2] ?? '').trim() }; return { cmdName: match[1]!, args: (match[2] ?? '').trim() };
} }
export function mergeCommandsByName(...lists: SlashCommandItem[][]): SlashCommandItem[] { export function mergeCommandsByName<T extends SlashCommandItem>(...lists: T[][]): T[] {
const byName = new Map<string, SlashCommandItem>(); const byName = new Map<string, T>();
for (const list of lists) { for (const list of lists) {
for (const cmd of list) { for (const cmd of list) {
byName.set(cmd.name, cmd); byName.set(cmd.name, cmd);