diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8a698954a0..19addf163f 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -1751,17 +1751,26 @@ impl AcpClient { } "available_commands_update" => { // Advertised slash commands (ACP slash-commands extension). - // Logged for observability; UI surfacing is a follow-up. - let names: Vec<&str> = update["availableCommands"] + // Forward the complete latest list through the encrypted observer + // stream so Desktop can offer commands for the originating agent. + let commands = update["availableCommands"] .as_array() - .map(|cmds| cmds.iter().filter_map(|c| c["name"].as_str()).collect()) + .cloned() .unwrap_or_default(); + let names: Vec<&str> = commands + .iter() + .filter_map(|command| command["name"].as_str()) + .collect(); tracing::info!( target: "acp::update", "available_commands_update: {} commands [{}]", names.len(), names.join(", ") ); + self.observe( + "available_commands_captured", + serde_json::json!({ "commands": commands }), + ); false } "session_info_update" => { @@ -3462,6 +3471,43 @@ mod tests { }) } + #[tokio::test] + async fn available_commands_update_emits_complete_semantic_snapshot() { + let mut client = spawn_inert_client().await; + let observer = ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 3); + + let msg = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "test-session", + "update": { + "sessionUpdate": "available_commands_update", + "availableCommands": [ + { "name": "review", "description": "Review changes" }, + { "name": "deploy", "description": "Ship it" } + ] + } + } + }); + let _ = client.handle_session_update(&msg); + + let events = observer.snapshot(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, "available_commands_captured"); + assert_eq!(events[0].agent_index, Some(3)); + assert_eq!( + events[0].payload, + serde_json::json!({ + "commands": [ + { "name": "review", "description": "Review changes" }, + { "name": "deploy", "description": "Ship it" } + ] + }) + ); + } + #[tokio::test] async fn active_run_id_sets_on_string() { let mut client = spawn_inert_client().await; diff --git a/desktop/src/features/agents/agentCommandCatalog.test.mjs b/desktop/src/features/agents/agentCommandCatalog.test.mjs new file mode 100644 index 0000000000..54f2094225 --- /dev/null +++ b/desktop/src/features/agents/agentCommandCatalog.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { + getAgentCommandCatalog, + parseAvailableCommandsPayload, + recordAvailableCommandsUpdate, + resetAgentCommandCatalogForTests, +} from "./agentCommandCatalog.ts"; + +const OWNER = "aa".repeat(32); +const OTHER_OWNER = "bb".repeat(32); +const AGENT = "cc".repeat(32); + +function installLocalStorage() { + const values = new Map(); + globalThis.window = { + localStorage: { + get length() { + return values.size; + }, + getItem: (key) => values.get(key) ?? null, + key: (index) => [...values.keys()][index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, String(value)), + }, + }; +} + +describe("agent command catalog", () => { + beforeEach(() => { + installLocalStorage(); + resetAgentCommandCatalogForTests(); + }); + + it("sanitizes, bounds, and deduplicates advertised commands", () => { + const commands = parseAvailableCommandsPayload({ + commands: [ + { name: "/review", description: " Review changes " }, + { name: "REVIEW", description: "duplicate" }, + { name: "bad name" }, + { name: "deploy", description: 42 }, + ], + }); + + assert.deepEqual(commands, [ + { name: "review", description: "Review changes" }, + { name: "deploy", description: null }, + ]); + }); + + it("keeps the latest complete command list per owner and agent", () => { + assert.equal( + recordAvailableCommandsUpdate(OWNER, AGENT, { + seq: 8, + timestamp: "2026-07-23T08:00:00Z", + payload: { commands: [{ name: "review", description: "Review" }] }, + }), + true, + ); + assert.equal( + recordAvailableCommandsUpdate(OWNER, AGENT, { + seq: 7, + timestamp: "2026-07-23T07:00:00Z", + payload: { commands: [{ name: "stale" }] }, + }), + false, + ); + + assert.deepEqual(getAgentCommandCatalog(OWNER).get(AGENT)?.commands, [ + { name: "review", description: "Review" }, + ]); + assert.equal(getAgentCommandCatalog(OTHER_OWNER).has(AGENT), false); + }); + + it("treats an empty update as authoritative removal of prior commands", () => { + recordAvailableCommandsUpdate(OWNER, AGENT, { + seq: 1, + timestamp: "2026-07-23T08:00:00Z", + payload: { commands: [{ name: "review" }] }, + }); + recordAvailableCommandsUpdate(OWNER, AGENT, { + seq: 2, + timestamp: "2026-07-23T08:01:00Z", + payload: { commands: [] }, + }); + + assert.deepEqual(getAgentCommandCatalog(OWNER).get(AGENT)?.commands, []); + }); + + it("hydrates a persisted owner-scoped catalog after restart", () => { + recordAvailableCommandsUpdate(OWNER, AGENT, { + seq: 3, + timestamp: "2026-07-23T08:00:00Z", + payload: { commands: [{ name: "review" }] }, + }); + resetAgentCommandCatalogForTests(); + + assert.deepEqual(getAgentCommandCatalog(OWNER).get(AGENT)?.commands, [ + { name: "review", description: null }, + ]); + }); +}); diff --git a/desktop/src/features/agents/agentCommandCatalog.ts b/desktop/src/features/agents/agentCommandCatalog.ts new file mode 100644 index 0000000000..5f45670d40 --- /dev/null +++ b/desktop/src/features/agents/agentCommandCatalog.ts @@ -0,0 +1,207 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; + +const STORAGE_PREFIX = "buzz-agent-command-catalog.v1"; +const MAX_COMMANDS_PER_AGENT = 256; +const MAX_COMMAND_NAME_LENGTH = 128; +const MAX_COMMAND_DESCRIPTION_LENGTH = 512; + +export type AgentCommand = { + name: string; + description: string | null; +}; + +export type AgentCommandCatalogEntry = { + commands: readonly AgentCommand[]; + seq: number; + timestamp: string; +}; + +export type AgentCommandCatalog = ReadonlyMap; + +type PersistedCatalog = { + version: 1; + agents: Record; +}; + +type AvailableCommandsEvent = { + payload: unknown; + seq: number; + timestamp: string; +}; + +const EMPTY_CATALOG: AgentCommandCatalog = new Map(); +// Managed-agent observer ingestion is owner-global; command capabilities belong +// to the agent pubkey rather than whichever community currently renders it. +const catalogByOwner = new Map(); +const hydratedOwners = new Set(); +const listeners = new Set<() => void>(); + +function storageKey(ownerPubkey: string): string { + return `${STORAGE_PREFIX}:${normalizePubkey(ownerPubkey)}`; +} + +function sanitizeCommand(value: unknown): AgentCommand | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + const record = value as Record; + if (typeof record.name !== "string") return null; + + const name = record.name.trim().replace(/^\/+/, ""); + if ( + name.length === 0 || + name.length > MAX_COMMAND_NAME_LENGTH || + /\s|\//u.test(name) + ) { + return null; + } + + const description = + typeof record.description === "string" + ? record.description.trim().slice(0, MAX_COMMAND_DESCRIPTION_LENGTH) || + null + : null; + return { name, description }; +} + +export function parseAvailableCommandsPayload( + payload: unknown, +): readonly AgentCommand[] | null { + if ( + typeof payload !== "object" || + payload === null || + Array.isArray(payload) + ) { + return null; + } + const commands = (payload as Record).commands; + if (!Array.isArray(commands)) return null; + + const parsed: AgentCommand[] = []; + const seen = new Set(); + for (const value of commands.slice(0, MAX_COMMANDS_PER_AGENT)) { + const command = sanitizeCommand(value); + if (!command) continue; + const key = command.name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + parsed.push(command); + } + return parsed; +} + +function parseStoredCatalog(raw: string | null): AgentCommandCatalog { + if (!raw) return EMPTY_CATALOG; + try { + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.version !== 1 || + !parsed.agents || + typeof parsed.agents !== "object" + ) { + return EMPTY_CATALOG; + } + const next = new Map(); + for (const [pubkey, entry] of Object.entries(parsed.agents)) { + if (!entry || typeof entry !== "object") continue; + const candidate = entry as Partial; + const commands = parseAvailableCommandsPayload({ + commands: candidate.commands, + }); + if ( + commands === null || + typeof candidate.seq !== "number" || + !Number.isSafeInteger(candidate.seq) || + typeof candidate.timestamp !== "string" + ) { + continue; + } + next.set(normalizePubkey(pubkey), { + commands, + seq: candidate.seq, + timestamp: candidate.timestamp, + }); + } + return next; + } catch { + return EMPTY_CATALOG; + } +} + +function hydrate(ownerPubkey: string): AgentCommandCatalog { + const owner = normalizePubkey(ownerPubkey); + if (!hydratedOwners.has(owner)) { + const raw = + typeof window === "undefined" + ? null + : window.localStorage.getItem(storageKey(owner)); + catalogByOwner.set(owner, parseStoredCatalog(raw)); + hydratedOwners.add(owner); + } + return catalogByOwner.get(owner) ?? EMPTY_CATALOG; +} + +function persist(ownerPubkey: string, catalog: AgentCommandCatalog): void { + if (typeof window === "undefined") return; + const agents = Object.fromEntries(catalog.entries()); + setLocalStorageItemWithRecovery( + storageKey(ownerPubkey), + JSON.stringify({ version: 1, agents } satisfies PersistedCatalog), + ); +} + +function isNewer( + incoming: Pick, + current: AgentCommandCatalogEntry | undefined, +): boolean { + if (!current) return true; + const incomingTime = Date.parse(incoming.timestamp); + const currentTime = Date.parse(current.timestamp); + if (Number.isFinite(incomingTime) && Number.isFinite(currentTime)) { + if (incomingTime !== currentTime) return incomingTime > currentTime; + } + return incoming.seq > current.seq; +} + +export function recordAvailableCommandsUpdate( + ownerPubkey: string, + agentPubkey: string, + event: AvailableCommandsEvent, +): boolean { + const commands = parseAvailableCommandsPayload(event.payload); + if (commands === null || !Number.isSafeInteger(event.seq)) return false; + + const owner = normalizePubkey(ownerPubkey); + const agent = normalizePubkey(agentPubkey); + const current = hydrate(owner); + if (!isNewer(event, current.get(agent))) return false; + + const next = new Map(current); + next.set(agent, { + commands, + seq: event.seq, + timestamp: event.timestamp, + }); + catalogByOwner.set(owner, next); + persist(owner, next); + for (const listener of listeners) listener(); + return true; +} + +export function getAgentCommandCatalog( + ownerPubkey: string | null, +): AgentCommandCatalog { + return ownerPubkey ? hydrate(ownerPubkey) : EMPTY_CATALOG; +} + +export function subscribeAgentCommandCatalog(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function resetAgentCommandCatalogForTests(): void { + catalogByOwner.clear(); + hydratedOwners.clear(); + listeners.clear(); +} diff --git a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs index c44c2a88e0..5b208e56f4 100644 --- a/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs +++ b/desktop/src/features/agents/ingestArchivedObserverEvents.test.mjs @@ -11,6 +11,10 @@ import assert from "node:assert/strict"; import { beforeEach, describe, it } from "node:test"; +import { + getAgentCommandCatalog, + resetAgentCommandCatalogForTests, +} from "@/features/agents/agentCommandCatalog.ts"; import { ingestArchivedObserverEvents, injectObserverEventsForE2E, @@ -74,6 +78,7 @@ function makeDecryptFail() { describe("ingestArchivedObserverEvents", () => { beforeEach(() => { resetAgentObserverStore(); + resetAgentCommandCatalogForTests(); }); it("test_unknown_agent_drops_event_before_decrypt", async () => { @@ -94,6 +99,28 @@ describe("ingestArchivedObserverEvents", () => { assert.equal(snap.events.length, 0); }); + it("hydrates command catalogs from trusted archived semantic frames", async () => { + _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); + const ownerPubkey = "c".repeat(64); + const commandEvent = makeObserverEvent({ + kind: "available_commands_captured", + payload: { + commands: [{ name: "review", description: "Review changes" }], + }, + }); + + await ingestArchivedObserverEvents( + [makeRawEvent()], + makeDecrypt(commandEvent), + async () => ownerPubkey, + ); + + assert.deepEqual( + getAgentCommandCatalog(ownerPubkey).get(AGENT_PUBKEY)?.commands, + [{ name: "review", description: "Review changes" }], + ); + }); + it("test_mismatched_sender_drops_event_before_decrypt", async () => { _testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]); let decryptCalled = false; @@ -687,6 +714,7 @@ describe("eager initial hydration loop control flow (production runHydrationLoop describe("archive window holds more than MAX_OBSERVER_EVENTS (3000) frames", () => { beforeEach(() => { resetAgentObserverStore(); + resetAgentCommandCatalogForTests(); }); it("test_archive_window_retains_all_events_beyond_3000_cap", async () => { @@ -804,6 +832,7 @@ import { mergeObserverEventWindows } from "@/features/agents/ui/agentSessionPane describe("archive page subscription notification", () => { beforeEach(() => { resetAgentObserverStore(); + resetAgentCommandCatalogForTests(); }); it("test_full_archive_page_notifies_subscribers", async () => { @@ -882,6 +911,7 @@ describe("archive page subscription notification", () => { describe("raw-event-level merge: stateful aggregates across live/archive boundary", () => { beforeEach(() => { resetAgentObserverStore(); + resetAgentCommandCatalogForTests(); }); it("test_tool_start_in_archive_plus_update_in_live_yields_complete_row", () => { diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 56c69f915a..e4350a1141 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -14,6 +14,7 @@ import { import { normalizePubkey } from "@/shared/lib/pubkey"; import { useQueryClient } from "@tanstack/react-query"; import { agentConfigSurfaceQueryKey } from "@/features/agents/hooks"; +import { recordAvailableCommandsUpdate } from "./agentCommandCatalog"; import type { ConnectionState, ObserverEvent, @@ -168,6 +169,7 @@ let unsubscribeRelay: (() => Promise) | null = null; let startPromise: Promise | null = null; let eventProcessingQueue: Promise = Promise.resolve(); let generation = 0; +let observerOwnerPubkey: string | null = null; function notifyListeners() { for (const listener of listeners) { @@ -401,6 +403,11 @@ async function handleRelayObserverEvent( if (parsed.kind === "session_config_captured") { void putAgentSessionConfig(agentPubkey, parsed.payload); onSessionConfigCaptured?.(agentPubkey); + } else if ( + parsed.kind === "available_commands_captured" && + observerOwnerPubkey + ) { + recordAvailableCommandsUpdate(observerOwnerPubkey, agentPubkey, parsed); } else if (parsed.kind === "control_result") { dispatchControlResult(agentPubkey, parsed.payload); } else if (parsed.kind === "managed_agent_runtime_lifecycle") { @@ -435,6 +442,10 @@ export function ensureRelayObserverSubscription() { setConnectionState("connecting", null); startPromise = (async () => { const identity = await getIdentity(); + if (activeGeneration !== generation) { + return; + } + observerOwnerPubkey = normalizePubkey(identity.pubkey); const unsubscribe = await subscribeToAgentObserverFrames( identity.pubkey, (event) => { @@ -654,14 +665,18 @@ export function useManagedAgentObserverBridge( * (e.g. an agent that is stopped but has archived history) are dropped. * The caller should ensure the agent is registered before calling. * - * `_decryptFn` is only used by tests to inject a mock decryption function. - * Production callers must always omit it. + * `_decryptFn` and `_ownerPubkeyFn` are only used by tests to inject mock + * functions. Production callers must always omit them. */ export async function ingestArchivedObserverEvents( rawEvents: RelayEvent[], _decryptFn: (event: RelayEvent) => Promise = decryptObserverEvent, + _ownerPubkeyFn: () => Promise = async () => + normalizePubkey((await getIdentity()).pubkey), ): Promise { + const activeGeneration = generation; let archiveChanged = false; + let archiveOwnerPubkey = observerOwnerPubkey; for (const event of rawEvents) { const agentPubkey = observerTag(event, "agent"); const frame = observerTag(event, "frame"); @@ -676,6 +691,12 @@ export async function ingestArchivedObserverEvents( } try { const parsed = (await _decryptFn(event)) as ObserverEvent; + if (activeGeneration !== generation) return; + if (parsed.kind === "available_commands_captured") { + archiveOwnerPubkey ??= normalizePubkey(await _ownerPubkeyFn()); + if (activeGeneration !== generation) return; + recordAvailableCommandsUpdate(archiveOwnerPubkey, agentPubkey, parsed); + } // Route archived events to the channel-scoped archive window (no cap) // rather than the per-agent live-relay store (MAX_OBSERVER_EVENTS cap). // Events without a channelId fall through to the live store so they @@ -741,6 +762,7 @@ export function resetAgentObserverStore() { unsubscribeRelay = null; startPromise = null; eventProcessingQueue = Promise.resolve(); + observerOwnerPubkey = null; eventsByAgent.clear(); transcriptByAgent.clear(); snapshotByAgent.clear(); diff --git a/desktop/src/features/agents/useAgentCommandCatalog.ts b/desktop/src/features/agents/useAgentCommandCatalog.ts new file mode 100644 index 0000000000..33ba02e29d --- /dev/null +++ b/desktop/src/features/agents/useAgentCommandCatalog.ts @@ -0,0 +1,14 @@ +import * as React from "react"; + +import { + getAgentCommandCatalog, + subscribeAgentCommandCatalog, +} from "./agentCommandCatalog"; + +export function useAgentCommandCatalog(ownerPubkey: string | null) { + return React.useSyncExternalStore( + subscribeAgentCommandCatalog, + () => getAgentCommandCatalog(ownerPubkey), + () => getAgentCommandCatalog(null), + ); +} diff --git a/desktop/src/features/messages/lib/slashCommandAutocomplete.test.mjs b/desktop/src/features/messages/lib/slashCommandAutocomplete.test.mjs new file mode 100644 index 0000000000..ebec3332ed --- /dev/null +++ b/desktop/src/features/messages/lib/slashCommandAutocomplete.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + buildSlashCommandGroups, + buildSlashCommandInsertText, + detectSlashCommandQuery, + resolveLeadingAgentMentionPubkeys, +} from "./slashCommandAutocomplete.ts"; + +const ALPHA = "aa".repeat(32); +const BETA = "bb".repeat(32); +const catalog = new Map([ + [ + ALPHA, + { + seq: 1, + timestamp: "2026-07-23T08:00:00Z", + commands: [ + { name: "review", description: "Review the current changes" }, + { name: "deploy", description: "Ship to production" }, + ], + }, + ], + [ + BETA, + { + seq: 1, + timestamp: "2026-07-23T08:00:00Z", + commands: [{ name: "review", description: "Independent review" }], + }, + ], +]); +const providers = [ + { pubkey: ALPHA, displayName: "Alpha" }, + { pubkey: BETA, displayName: "Beta" }, +]; + +describe("slash command autocomplete", () => { + it("detects a slash at message start and after leading agent mentions", () => { + assert.deepEqual(detectSlashCommandQuery("/rev", 4), { + leadingText: "", + query: "rev", + replaceFromOffset: 0, + }); + assert.deepEqual(detectSlashCommandQuery("@Alpha /dep", 11), { + leadingText: "@Alpha ", + query: "dep", + replaceFromOffset: 7, + }); + }); + + it("resolves selected or manually typed leading member-agent mentions", () => { + assert.deepEqual( + resolveLeadingAgentMentionPubkeys("@Alpha ", [ + { displayName: "Alpha", pubkey: ALPHA }, + ]), + [ALPHA], + ); + assert.deepEqual( + resolveLeadingAgentMentionPubkeys("@Alpha @Beta ", [ + { displayName: "Alpha", pubkey: ALPHA }, + { displayName: "Beta", pubkey: BETA }, + ]), + [ALPHA, BETA], + ); + assert.deepEqual( + resolveLeadingAgentMentionPubkeys("@Alpha hello ", [ + { displayName: "Alpha", pubkey: ALPHA }, + ]), + [], + ); + }); + + it("does not trigger for inline paths, arguments, or multi-line text", () => { + assert.equal(detectSlashCommandQuery("please /review", 14), null); + assert.equal(detectSlashCommandQuery("/review now", 11), null); + assert.equal(detectSlashCommandQuery("hello\n/review", 13), null); + }); + + it("routes commands chosen at message start through the provider mention", () => { + const [group] = buildSlashCommandGroups({ + catalog, + providers: [providers[0]], + query: "rev", + selectedAgentPubkeys: null, + }); + const [suggestion] = group.commands; + + assert.equal( + buildSlashCommandInsertText(suggestion, false), + "@Alpha /review ", + ); + assert.equal(buildSlashCommandInsertText(suggestion, true), "/review "); + }); + + it("groups duplicate command names by provider and narrows to mentions", () => { + const all = buildSlashCommandGroups({ + catalog, + providers, + query: "rev", + selectedAgentPubkeys: null, + }); + assert.deepEqual( + all.map((group) => [group.agentDisplayName, group.commands[0].name]), + [ + ["Alpha", "review"], + ["Beta", "review"], + ], + ); + + const mentioned = buildSlashCommandGroups({ + catalog, + providers, + query: "", + selectedAgentPubkeys: [BETA], + }); + assert.deepEqual( + mentioned.map((group) => group.agentPubkey), + [BETA], + ); + }); + + it("ranks name prefixes before infix and description matches", () => { + const rankedCatalog = new Map([ + [ + ALPHA, + { + seq: 1, + timestamp: "2026-07-23T08:00:00Z", + commands: [ + { name: "preview", description: null }, + { name: "review", description: null }, + { name: "inspect", description: "review changes" }, + ], + }, + ], + ]); + const [group] = buildSlashCommandGroups({ + catalog: rankedCatalog, + providers: [providers[0]], + query: "rev", + selectedAgentPubkeys: null, + }); + assert.deepEqual( + group.commands.map((command) => command.name), + ["review", "preview", "inspect"], + ); + }); +}); diff --git a/desktop/src/features/messages/lib/slashCommandAutocomplete.ts b/desktop/src/features/messages/lib/slashCommandAutocomplete.ts new file mode 100644 index 0000000000..adef534afb --- /dev/null +++ b/desktop/src/features/messages/lib/slashCommandAutocomplete.ts @@ -0,0 +1,170 @@ +import type { AgentCommandCatalog } from "@/features/agents/agentCommandCatalog"; + +export type SlashCommandProvider = { + pubkey: string; + displayName: string; +}; + +export type AgentMentionCandidate = { + displayName: string; + pubkey: string; +}; + +export type SlashCommandSuggestion = { + agentDisplayName: string; + agentPubkey: string; + description: string | null; + name: string; +}; + +export type SlashCommandGroup = { + agentDisplayName: string; + agentPubkey: string; + commands: readonly SlashCommandSuggestion[]; +}; + +export type SlashCommandQuery = { + leadingText: string; + query: string; + replaceFromOffset: number; +}; + +export function detectSlashCommandQuery( + value: string, + cursorPosition: number, +): SlashCommandQuery | null { + const beforeCursor = value.slice(0, cursorPosition); + if (beforeCursor.includes("\n")) return null; + + const slashIndex = beforeCursor.lastIndexOf("/"); + if (slashIndex < 0) return null; + const leadingText = beforeCursor.slice(0, slashIndex); + const query = beforeCursor.slice(slashIndex + 1); + if (/\s|\//u.test(query)) return null; + if ( + leadingText.length > 0 && + (!leadingText.startsWith("@") || !/\s$/u.test(leadingText)) + ) { + return null; + } + + return { leadingText, query, replaceFromOffset: slashIndex }; +} + +export function resolveLeadingAgentMentionPubkeys( + leadingText: string, + candidates: readonly AgentMentionCandidate[], +): string[] { + let remaining = leadingText; + const pubkeys: string[] = []; + const seen = new Set(); + const names = [ + ...new Set(candidates.map((candidate) => candidate.displayName)), + ] + .filter(Boolean) + .sort((left, right) => right.length - left.length); + + while (remaining.startsWith("@")) { + const lowerRemaining = remaining.toLowerCase(); + const name = names.find((candidate) => { + const token = `@${candidate.toLowerCase()}`; + return ( + lowerRemaining.startsWith(token) && + (remaining.length === token.length || + /\s/u.test(remaining.charAt(token.length))) + ); + }); + if (!name) return []; + + for (const candidate of candidates) { + if (candidate.displayName.toLowerCase() !== name.toLowerCase()) continue; + const normalized = candidate.pubkey.toLowerCase(); + if (!seen.has(normalized)) { + seen.add(normalized); + pubkeys.push(normalized); + } + } + + remaining = remaining.slice(name.length + 1); + const whitespace = remaining.match(/^\s+/u)?.[0] ?? ""; + if (!whitespace) return []; + remaining = remaining.slice(whitespace.length); + } + + return remaining.length === 0 ? pubkeys : []; +} + +export function buildSlashCommandInsertText( + suggestion: SlashCommandSuggestion, + hasLeadingAgentMention: boolean, +): string { + const command = `/${suggestion.name} `; + return hasLeadingAgentMention + ? command + : `@${suggestion.agentDisplayName} ${command}`; +} + +function commandRank( + name: string, + description: string | null, + query: string, +): number | null { + if (!query) return 0; + const lowerName = name.toLowerCase(); + const lowerQuery = query.toLowerCase(); + if (lowerName.startsWith(lowerQuery)) return 0; + if (lowerName.includes(lowerQuery)) return 1; + if (description?.toLowerCase().includes(lowerQuery)) return 2; + return null; +} + +export function buildSlashCommandGroups({ + catalog, + providers, + query, + selectedAgentPubkeys, +}: { + catalog: AgentCommandCatalog; + providers: readonly SlashCommandProvider[]; + query: string; + selectedAgentPubkeys: readonly string[] | null; +}): SlashCommandGroup[] { + const selected = selectedAgentPubkeys + ? new Set(selectedAgentPubkeys.map((pubkey) => pubkey.toLowerCase())) + : null; + + return providers + .filter( + (provider) => !selected || selected.has(provider.pubkey.toLowerCase()), + ) + .map((provider) => { + const commands = ( + catalog.get(provider.pubkey.toLowerCase())?.commands ?? [] + ) + .map((command) => ({ + command, + rank: commandRank(command.name, command.description, query), + })) + .filter( + (entry): entry is typeof entry & { rank: number } => + entry.rank !== null, + ) + .sort( + (left, right) => + left.rank - right.rank || + left.command.name.localeCompare(right.command.name), + ) + .map(({ command }) => ({ + agentDisplayName: provider.displayName, + agentPubkey: provider.pubkey, + description: command.description, + name: command.name, + })); + return { + agentDisplayName: provider.displayName, + agentPubkey: provider.pubkey, + commands, + }; + }) + .filter((group) => group.commands.length > 0); +} diff --git a/desktop/src/features/messages/lib/useSlashCommandAutocomplete.ts b/desktop/src/features/messages/lib/useSlashCommandAutocomplete.ts new file mode 100644 index 0000000000..d9f80be5a0 --- /dev/null +++ b/desktop/src/features/messages/lib/useSlashCommandAutocomplete.ts @@ -0,0 +1,180 @@ +import * as React from "react"; + +import { useAgentCommandCatalog } from "@/features/agents/useAgentCommandCatalog"; +import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import type { AutocompleteEdit } from "./useRichTextEditor"; +import { + buildSlashCommandInsertText, + buildSlashCommandGroups, + detectSlashCommandQuery, + resolveLeadingAgentMentionPubkeys, + type SlashCommandQuery, + type SlashCommandSuggestion, +} from "./slashCommandAutocomplete"; + +type ActiveQuery = { + detected: SlashCommandQuery; + selectedAgentPubkeys: readonly string[] | null; + signature: string; +}; + +export function useSlashCommandAutocomplete({ + channelId, + ownerPubkey, +}: { + channelId: string | null; + ownerPubkey: string | null; +}) { + const membersQuery = useChannelMembersQuery(channelId, Boolean(channelId)); + const catalog = useAgentCommandCatalog(ownerPubkey); + const [activeQuery, setActiveQuery] = React.useState( + null, + ); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const dismissedSignatureRef = React.useRef(null); + + const providers = React.useMemo( + () => + (membersQuery.data ?? []) + .filter((member) => member.isAgent || member.role === "bot") + .map((member) => ({ + pubkey: normalizePubkey(member.pubkey), + displayName: + member.displayName?.trim() || truncatePubkey(member.pubkey), + })), + [membersQuery.data], + ); + + const groups = React.useMemo( + () => + activeQuery + ? buildSlashCommandGroups({ + catalog, + providers, + query: activeQuery.detected.query, + selectedAgentPubkeys: activeQuery.selectedAgentPubkeys, + }) + : [], + [activeQuery, catalog, providers], + ); + const suggestions = React.useMemo( + () => groups.flatMap((group) => group.commands), + [groups], + ); + const isOpen = activeQuery !== null && suggestions.length > 0; + + React.useEffect(() => { + setSelectedIndex((current) => + suggestions.length === 0 ? 0 : Math.min(current, suggestions.length - 1), + ); + }, [suggestions.length]); + + const updateQuery = React.useCallback( + (value: string, cursorPosition: number) => { + const detected = detectSlashCommandQuery(value, cursorPosition); + if (!detected) { + dismissedSignatureRef.current = null; + setActiveQuery(null); + setSelectedIndex(0); + return; + } + + let selectedAgentPubkeys: readonly string[] | null = null; + if (detected.leadingText) { + selectedAgentPubkeys = resolveLeadingAgentMentionPubkeys( + detected.leadingText, + providers, + ); + if (selectedAgentPubkeys.length === 0) { + setActiveQuery(null); + setSelectedIndex(0); + return; + } + } + + const signature = `${detected.replaceFromOffset}:${detected.leadingText}:${detected.query}`; + if (dismissedSignatureRef.current === signature) { + setActiveQuery(null); + return; + } + dismissedSignatureRef.current = null; + setActiveQuery({ detected, selectedAgentPubkeys, signature }); + setSelectedIndex(0); + }, + [providers], + ); + + const insertCommand = React.useCallback( + ( + suggestion: SlashCommandSuggestion, + selectionEnd: number, + ): AutocompleteEdit | null => { + if (!activeQuery) return null; + const edit = { + replaceFromOffset: activeQuery.detected.replaceFromOffset, + replaceToOffset: selectionEnd, + insertText: buildSlashCommandInsertText( + suggestion, + activeQuery.selectedAgentPubkeys !== null, + ), + }; + setActiveQuery(null); + setSelectedIndex(0); + return edit; + }, + [activeQuery], + ); + + const handleKeyDown = React.useCallback( + ( + event: React.KeyboardEvent, + ): { handled: boolean; suggestion?: SlashCommandSuggestion } => { + if (!isOpen || !activeQuery) return { handled: false }; + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((current) => + current < suggestions.length - 1 ? current + 1 : 0, + ); + return { handled: true }; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((current) => + current > 0 ? current - 1 : suggestions.length - 1, + ); + return { handled: true }; + } + if ( + event.key === "Tab" || + (event.key === "Enter" && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey) + ) { + event.preventDefault(); + return { handled: true, suggestion: suggestions[selectedIndex] }; + } + if (event.key === "Escape") { + event.preventDefault(); + dismissedSignatureRef.current = activeQuery.signature; + setActiveQuery(null); + setSelectedIndex(0); + return { handled: true }; + } + return { handled: false }; + }, + [activeQuery, isOpen, selectedIndex, suggestions], + ); + + return { + groups, + handleKeyDown, + insertCommand, + isOpen, + selectedIndex, + suggestions, + updateQuery, + }; +} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index df3a734cee..db856935b9 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -23,6 +23,8 @@ import { import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditing"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { useMentions } from "@/features/messages/lib/useMentions"; +import { useSlashCommandAutocomplete } from "@/features/messages/lib/useSlashCommandAutocomplete"; +import type { SlashCommandSuggestion } from "@/features/messages/lib/slashCommandAutocomplete"; import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; import { useIdentityQuery } from "@/shared/api/hooks"; @@ -50,6 +52,7 @@ import { type MentionSuggestion, } from "./MentionAutocomplete"; import { ComposerDockToolbar } from "./ComposerDockToolbar"; +import { SlashCommandAutocomplete } from "./SlashCommandAutocomplete"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useMentionSendFlow } from "./useMentionSendFlow"; import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration"; @@ -133,6 +136,10 @@ function MessageComposerImpl({ const mentions = useMentions(channelId, undefined, profiles, { channelType, }); + const slashCommands = useSlashCommandAutocomplete({ + channelId, + ownerPubkey, + }); const channelLinks = useChannelLinks(); const customEmoji = useCustomEmoji(); const emojiAutocomplete = useEmojiAutocomplete(customEmoji); @@ -178,6 +185,7 @@ function MessageComposerImpl({ setIsEmojiPickerOpen(false); channelLinks.clearChannels(); emojiAutocomplete.clearEmojis(); + slashCommands.updateQuery("", 0); }, [effectiveDraftKey]); const disabledRef = React.useRef(disabled); @@ -203,7 +211,8 @@ function MessageComposerImpl({ isAutocompleteOpenRef.current = mentions.isMentionOpen || channelLinks.isChannelOpen || - emojiAutocomplete.isEmojiAutocompleteOpen; + emojiAutocomplete.isEmojiAutocompleteOpen || + slashCommands.isOpen; const submitMessageRef = React.useRef<() => void>(() => {}); const composerScrollRef = React.useRef(null); @@ -259,6 +268,7 @@ function MessageComposerImpl({ mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); emojiAutocomplete.updateEmojiQuery(text, cursor); + slashCommands.updateQuery(text, cursor); persistentMentionHydrationRef.current?.reconcile(text); @@ -437,6 +447,27 @@ function MessageComposerImpl({ ], ); + const applySlashCommandInsert = React.useCallback( + (suggestion: SlashCommandSuggestion) => { + const { cursor } = richText.getPlainTextAndCursor(); + const edit = slashCommands.insertCommand(suggestion, cursor); + if (edit) { + mentions.registerMentionPubkey( + suggestion.agentDisplayName, + suggestion.agentPubkey, + { isAgent: true }, + ); + applyAutocompleteEdit(edit); + } + }, + [ + applyAutocompleteEdit, + mentions.registerMentionPubkey, + richText.getPlainTextAndCursor, + slashCommands.insertCommand, + ], + ); + // ── Emoji insertion ───────────────────────────────────────────────── const insertEmoji = React.useCallback( (emoji: string) => { @@ -695,6 +726,14 @@ function MessageComposerImpl({ const handleEditorKeyDown = React.useCallback( (event: React.KeyboardEvent) => { // Let autocomplete handle keys first + const slashCommandResult = slashCommands.handleKeyDown(event); + if (slashCommandResult.handled) { + if (slashCommandResult.suggestion) { + applySlashCommandInsert(slashCommandResult.suggestion); + } + return; + } + const emojiResult = emojiAutocomplete.handleEmojiKeyDown(event); if (emojiResult.handled) { if (emojiResult.suggestion) { @@ -735,6 +774,8 @@ function MessageComposerImpl({ } }, [ + slashCommands.handleKeyDown, + applySlashCommandInsert, emojiAutocomplete.handleEmojiKeyDown, applyEmojiInsert, channelLinks.handleChannelKeyDown, @@ -920,6 +961,11 @@ function MessageComposerImpl({ }} > {ownsDropZone && media.isDragOver && } + void; + selectedIndex: number; +}; + +export const SlashCommandAutocomplete = React.memo( + function SlashCommandAutocomplete({ + groups, + onSelect, + selectedIndex, + }: SlashCommandAutocompleteProps) { + const listRef = React.useRef(null); + + React.useEffect(() => { + listRef.current + ?.querySelector(`[data-command-index="${selectedIndex}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + if (groups.length === 0) return null; + + let commandIndex = -1; + return ( +
+
+ {groups.map((group) => ( +
+
+
+ {group.commands.map((command) => { + commandIndex += 1; + const index = commandIndex; + return ( + + ); + })} +
+ ))} +
+
+ ); + }, +); diff --git a/desktop/src/shared/lib/localStorageQuota.ts b/desktop/src/shared/lib/localStorageQuota.ts index 538003ef6f..0a3729e691 100644 --- a/desktop/src/shared/lib/localStorageQuota.ts +++ b/desktop/src/shared/lib/localStorageQuota.ts @@ -12,6 +12,7 @@ const PURE_CACHE_KEY_PREFIXES = [ "buzz-channels.v1:", "buzz-sidebar-skeleton-shape.v1:", "buzz-timeline-skeleton-shape.v1:", + "buzz-agent-command-catalog.v1", ]; const QUOTA_RECOVERY_MARKER_KEY = "buzz-local-storage-quota-recovery.v1"; diff --git a/docs/assets/screenshots/agent-slash-commands/01-channel-bare-slash-palette.jpg b/docs/assets/screenshots/agent-slash-commands/01-channel-bare-slash-palette.jpg new file mode 100644 index 0000000000..257984654a Binary files /dev/null and b/docs/assets/screenshots/agent-slash-commands/01-channel-bare-slash-palette.jpg differ diff --git a/docs/assets/screenshots/agent-slash-commands/02-channel-after-mention-slash-palette.jpg b/docs/assets/screenshots/agent-slash-commands/02-channel-after-mention-slash-palette.jpg new file mode 100644 index 0000000000..ad2db49081 Binary files /dev/null and b/docs/assets/screenshots/agent-slash-commands/02-channel-after-mention-slash-palette.jpg differ diff --git a/docs/assets/screenshots/agent-slash-commands/03-thread-help-reply.jpg b/docs/assets/screenshots/agent-slash-commands/03-thread-help-reply.jpg new file mode 100644 index 0000000000..3e54d7d695 Binary files /dev/null and b/docs/assets/screenshots/agent-slash-commands/03-thread-help-reply.jpg differ diff --git a/docs/assets/screenshots/agent-slash-commands/04-thread-composer-slash-palette.jpg b/docs/assets/screenshots/agent-slash-commands/04-thread-composer-slash-palette.jpg new file mode 100644 index 0000000000..cfaced1a9e Binary files /dev/null and b/docs/assets/screenshots/agent-slash-commands/04-thread-composer-slash-palette.jpg differ