diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index 9fa3e1db..0f60e07a 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -18,7 +18,7 @@ parts of `packages/presentation/ui` (`chat`/`shell`) and `packages/client/workbe - **coss-ui first.** Reach for `Card`/`CardFrame`/`CardPanel` instead of hand-writing borders/padding; `Field`+`FieldLabel`+`FieldDescription`+`Input` instead of custom inputs; `Button`, `Sidebar`, `Tabs`, `Combobox`, `Empty`, `Skeleton`, `Badge`, etc. Compose with `render={}` where coss-ui supports it. Only hand-roll when no primitive exists. - **Never edit `coss-ui` source.** If you must customize, "fork" by copying the minimal needed implementation into the consuming package, reusing coss-ui exports as much as possible. - **lucide icons: import the `Icon`-suffixed variant** (`SearchIcon`, not `Search`). -- **Brand / agent icons: `@proj-airi/lobe-icons` via `unplugin-icons`** — chosen over `simple-icons`, which at adoption time (2026-07) lacked usable `claudecode`/`opencode` glyphs after trademark removals. Adding any `~icons/*` import is a four-point wiring or it silently fails an icon, breaks vitest, or fails lint: (1) register the `Icons({ compiler: 'jsx', jsx: 'react', customCollections: ExternalPackageIconLoader('@proj-airi/lobe-icons') })` plugin in **all three** configs — `apps/webview/vite.config.ts`, `apps/desktop/vite.renderer.config.ts`, and root `vitest.config.ts` (miss the last and any test transitively importing the module fails to load); (2) add `/// ` at the top of every source file that imports `~icons`; (3) add that file to the eslint override that ignores `^~icons/` for `import-x/no-unresolved`; (4) keep `unplugin-icons` / `@svgr` / `@proj-airi/lobe-icons` in the pnpm catalog. lobe-icons names are lowercase, no separator (`claudecode`/`codex`/`opencode`); there is no `pi` glyph (it falls back to initials). +- **Brand / agent icons: `@proj-airi/lobe-icons` via `unplugin-icons`** — chosen over `simple-icons` for AI agent/model brands, which at adoption time (2026-07) lacked usable `claudecode`/`opencode` glyphs after trademark removals. Third-party integration glyphs (MCP servers: Linear, Slack, GitHub, …) use static `~icons/simple-icons/*` imports backed by `@iconify-json/simple-icons` instead (`chat/integration-brand.tsx` is the one map) — lobe-icons stays the agent/model collection. Adding any `~icons/*` import is a four-point wiring or it silently fails an icon, breaks vitest, or fails lint: (1) register the `Icons({ compiler: 'jsx', jsx: 'react', customCollections: ExternalPackageIconLoader('@proj-airi/lobe-icons') })` plugin in **all three** configs — `apps/webview/vite.config.ts`, `apps/desktop/vite.renderer.config.ts`, and root `vitest.config.ts` (miss the last and any test transitively importing the module fails to load); (2) add `/// ` at the top of every source file that imports `~icons`; (3) add that file to the eslint override that ignores `^~icons/` for `import-x/no-unresolved`; (4) keep `unplugin-icons` / `@svgr` / `@proj-airi/lobe-icons` in the pnpm catalog. lobe-icons names are lowercase, no separator (`claudecode`/`codex`/`opencode`); there is no `pi` glyph (it falls back to initials). - File-identity icons use static `~icons/material-icon-theme/*` imports backed by `@iconify-json/material-icon-theme`; declare the icon set in the root, webview, and desktop build packages. Use it only for a specific language, tool, brand, or branded file format. Folders and generic file categories stay on Lucide, and Material icons keep their own colors (size classes only; no `fill`, `stroke`, or `text-*`). Never construct a virtual icon import path dynamically. - **Adding a new agent kind starts in the schema:** extend the `AgentKind` enum (`z.enum(['claude-code','codex','opencode','pi'])`) in `packages/foundation/schema/src/common.ts` — it is the data-plane→presentation join key — then fill `AGENT_LABELS` and `AGENT_INITIALS` in `packages/presentation/ui/src/chat/agent-icon.tsx` (both `Record` — typecheck-enforced) and add the brand glyph to the `Partial` glyph map if one exists (a missing glyph deliberately falls back to initials). `AgentIcon` there is the single brand-chip component (`solid`/`ghost` variants). - **Type scale bottoms out at `text-2xs`** (11px — badges, chrome labels); never write ad-hoc pixel sizes like `text-[13px]` — body/secondary/caption are `text-sm`/`text-xs`/`text-2xs`. Below `text-muted-foreground`, dimmer text uses the semantic tiers `text-label-tertiary` (timestamps, weak hints) and `text-label-quaternary` (placeholders, pending edges), never ad-hoc `/NN` opacities (fills like status dots are exempt). Numeric readouts outside `font-mono` take `tabular-nums`. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 529e6264..501a6f47 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -49,6 +49,7 @@ "@electron/asar": "^4.2.1", "@hookform/resolvers": "^5.5.7", "@iconify-json/material-icon-theme": "catalog:", + "@iconify-json/simple-icons": "catalog:", "@linkcode/ui": "workspace:*", "@linkcode/workbench": "workspace:*", "@proj-airi/lobe-icons": "catalog:", diff --git a/apps/webview/package.json b/apps/webview/package.json index e82e5ff0..4db52373 100644 --- a/apps/webview/package.json +++ b/apps/webview/package.json @@ -40,6 +40,7 @@ }, "devDependencies": { "@iconify-json/material-icon-theme": "catalog:", + "@iconify-json/simple-icons": "catalog:", "@proj-airi/lobe-icons": "catalog:", "@rolldown/plugin-babel": "catalog:", "@svgr/core": "catalog:", diff --git a/eslint.config.cjs b/eslint.config.cjs index c83e8b31..8674b90b 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -122,6 +122,7 @@ module.exports = require('eslint-config-sukka').sukka( name: 'linkcode/unplugin-icons-virtual-modules', files: [ 'packages/presentation/ui/src/chat/agent-icon.tsx', + 'packages/presentation/ui/src/chat/integration-brand.tsx', 'packages/presentation/ui/src/lib/__tests__/file-icon.test.ts', 'packages/presentation/ui/src/lib/material-file-icons.ts', 'packages/presentation/ui/src/shell/service-icon.tsx', diff --git a/package.json b/package.json index 336d7c0c..c3f9b589 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@biomejs/biome": "^2.5.5", "@eslint-sukka/react": "^8.14.2", "@iconify-json/material-icon-theme": "catalog:", + "@iconify-json/simple-icons": "catalog:", "@proj-airi/lobe-icons": "catalog:", "@svgr/core": "catalog:", "@svgr/plugin-jsx": "catalog:", diff --git a/packages/client/workbench/src/mock/data/commands.ts b/packages/client/workbench/src/mock/data/commands.ts index bb4365ec..ee7f5fe8 100644 --- a/packages/client/workbench/src/mock/data/commands.ts +++ b/packages/client/workbench/src/mock/data/commands.ts @@ -22,6 +22,26 @@ const MOCK_COMMAND_FIXTURES: MockCommandFixture[] = [ }, reply: 'Mock review complete: no blocking issues found.', }, + { + command: { + name: 'documents', + description: 'Create and edit Word and Google Docs files', + displayName: 'Documents', + brandColor: '#2563EB', + iconDataUri: + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNiIgZmlsbD0iIzI1NjNFQiIvPjxwYXRoIGQ9Ik03IDZoN2wzIDN2OUg3eiIgZmlsbD0id2hpdGUiLz48L3N2Zz4=', + }, + reply: 'Mock document created.', + }, + { + command: { + name: 'sync-linear', + description: 'Sync issues into the tracker', + displayName: 'Linear', + brandColor: '#5E6AD2', + }, + reply: 'Mock issues synced.', + }, { command: { name: 'usage', diff --git a/packages/client/workbench/src/mock/data/showcase.ts b/packages/client/workbench/src/mock/data/showcase.ts index 452d9e43..dca4fe44 100644 --- a/packages/client/workbench/src/mock/data/showcase.ts +++ b/packages/client/workbench/src/mock/data/showcase.ts @@ -658,6 +658,16 @@ export function createShowcaseToolBursts(terminalId = SHOWCASE_TERMINAL_ID): Sho rawInput: { id: 'CODE-228', includeRelations: true }, rawOutput: { content: [{ type: 'text', text: 'CODE-228' }] }, }, + // Adjacent same-brand calls form a dedicated brand group ("Used Linear 2 times"). + { + toolCallId: 'mock-tool-mcp-slug-save', + title: 'mcp__linear__save_issue', + kind: 'other', + status: 'completed', + content: [{ type: 'content', content: textBlock('CODE-228 moved to In Review.') }], + rawInput: { id: 'CODE-228', state: 'In Review' }, + rawOutput: { content: [{ type: 'text', text: 'CODE-228' }] }, + }, { toolCallId: 'mock-tool-task-review', title: 'Review metadata policy', diff --git a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts index 31893341..997aef93 100644 --- a/packages/client/workbench/tests/integration/dev-mock-transport.test.ts +++ b/packages/client/workbench/tests/integration/dev-mock-transport.test.ts @@ -13,6 +13,7 @@ import { describe, expect, it } from 'vitest'; import { createDevMockTransport } from '../../src/mock/dev-mock-transport'; const rBlobUrl = /^blob:/; +const RE_SVG_DATA_URI = /^data:image\/svg\+xml;base64,/; async function connectedClient(): Promise { const client = new LinkCodeClient(createDevMockTransport()); @@ -230,6 +231,19 @@ describe('dev mock transport', () => { description: 'Review the current changes', argumentHint: '', }, + { + name: 'documents', + description: 'Create and edit Word and Google Docs files', + displayName: 'Documents', + brandColor: '#2563EB', + iconDataUri: expect.stringMatching(RE_SVG_DATA_URI), + }, + { + name: 'sync-linear', + description: 'Sync issues into the tracker', + displayName: 'Linear', + brandColor: '#5E6AD2', + }, { name: 'usage', description: 'Show session usage and rate limits', diff --git a/packages/foundation/schema/src/model/agent/input.ts b/packages/foundation/schema/src/model/agent/input.ts index 1a00b8b9..c3880eb5 100644 --- a/packages/foundation/schema/src/model/agent/input.ts +++ b/packages/foundation/schema/src/model/agent/input.ts @@ -94,6 +94,17 @@ export const AgentCommandSchema = z.object({ /** Alternate names that invoke the same command (claude-code, e.g. `/cost` → `/usage`), no * leading slash. Input matching accepts them; menus display only the canonical `name`. */ aliases: z.array(z.string().min(1)).optional(), + /** Provider-supplied human name (codex plugin skills: "Documents"), shown beside `name`. */ + displayName: z.string().min(1).optional(), + /** Small brand icon embedded as a data URI (no asset endpoint exists) — size-capped and, for + * SVG, active-content-screened at adapter ingest. Render via `` only; never inline SVG + * markup into the DOM — the screen is depth, not a sanitizer. */ + iconDataUri: z.string().startsWith('data:image/').optional(), + /** Brand accent for icon fallbacks (menu initial chips). */ + brandColor: z + .string() + .regex(/^#[0-9A-F]{6}$/i) + .optional(), }); export type AgentCommand = z.infer; diff --git a/packages/foundation/schema/src/wire/message.ts b/packages/foundation/schema/src/wire/message.ts index 8445c3c4..d8bd5a45 100644 --- a/packages/foundation/schema/src/wire/message.ts +++ b/packages/foundation/schema/src/wire/message.ts @@ -9,7 +9,7 @@ import { WIRE_PAYLOAD_KINDS, WirePayloadSchema } from './payload'; */ /** Stamped on every frame this build sends; bump on any wire schema change. */ -export const WIRE_PROTOCOL_VERSION = 73 as const; +export const WIRE_PROTOCOL_VERSION = 74 as const; /** The oldest `v` this build still accepts. Bump only for a breaking change — a variant or field * removed, renamed, or given a new meaning; additive changes leave it alone. */ diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index a4d0e70f..0558eda7 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -92,7 +92,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they - **opencode** — `consumeEvents()` keeps one active `event.subscribe({directory: cwd})` and resubscribes after a clean SSE close at normal turn end (`session.idle`) or on cancel. The directory scope is LOAD-BEARING: events ride a per-directory instance bus, so a bare `subscribe()` silently misses every session event whenever the daemon cwd differs from the session cwd (verified live on 1.17.11). A close is fatal ONLY while a turn is active with no cancel pending, and the fatal path emits status `stopped` (NOT `idle`) so the UI disables the composer — misclassifying it (the pre-fix bug) stranded the composer enabled against a dead adapter. Each event has its own try/catch; the resubscribe delay prevents an empty-response busy loop. - **opencode control plane** (CODE-224, live-verified on binary 1.18.2 × SDK 1.17.18 — script + readback transcript attached to the issue): `set-model` and `set-approval-policy` are pure store-then-emit — the pick is resent on every `session.promptAsync`/`session.command` as the `model`/`agent` fields, and a mid-session change routes the very next turn (assistant `providerID`/`modelID`/`agent` readback all flip; next-turn semantics, in-flight turns unaffected). User and assistant `message.updated` frames reflect the actually routed `providerID/modelID`, including the native default when no override was sent. No dedicated switched/ack event fires on the legacy bus, so the immediate reflect is the only switch confirmation channel. `set-model` rejects refs that aren't `providerID/modelID` (a stored bare id would emit a "successful" model-update while prompts silently omit the field) and rejects cross-provider switches when a per-account credential was injected at spawn (the injection is spawn-time-only, scoped to one provider). **The approval-policy axis IS opencode's agent axis**: selectable agents from `app.agents({directory})` (`mode === 'primary'|'all'`, non-hidden — hidden primaries like `compaction`/`title` and subagents are excluded) are advertised as policies, default = first primary (the TUI's own default); permission posture stays config-driven (CODE-136). The axis is dynamic: a failed discovery at start hides it for the session (a later `$` shell command retries and re-arms it on success). `$` shell runs under the selected agent. Resume adopts the Session record's last-used `model`/`agent` (both live-verified to update after every turn) unless `StartOptions.model` overrides; a credential-carrying resume without an explicit model pre-reads that record off the shared history server BEFORE the spawn, because the credential injection is spawn-time-only and keyed by the model's provider. There is NO effort axis — opencode's only analogue is per-model `variant` keys (free strings, incompatible with the closed `EffortLevel` enum; a follow-up on the dynamic catalog). **The model catalog is adapter-advertised** (CODE-226): `provider.list({directory})` at start → `available-models-update` (full-replace, engine-cached, attach-replayed — the command-catalog contract), filtered to connected / key-less `api`-source providers and narrowed to the credential-injected provider when one is in play; the composer prefers this catalog over the static `AGENT_MODEL_OPTIONS` table (which deliberately has no opencode entry — its model set is provider-dependent, not a fixed vendor list). - **opencode turn lifecycle** (all verified live on 1.17.11, CODE-136): prompts go through `session.promptAsync` — the blocking `session.prompt` holds its HTTP response open for the whole turn, so `send()` would not return until the turn ended. `session.status {busy|retry}` is the on-stream acknowledgement that the active turn is running, and it ALWAYS precedes the turn's own error/idle — the `turnStarted` gate built on it is what keeps the previous turn's post-settle stragglers (an abort's DUPLICATE idle; the error re-fired with a stack after the settle) from falsely settling or poisoning a next turn that was already dispatched. An abort delivers `session.error {MessageAbortedError}` + `session.idle` — the error folds into the cancel path (stop `cancelled`), never surfaces as an error. Other `session.error`s fail the turn: `ProviderAuthError` → `AUTH_FAILED_ERROR_CODE` (non-recoverable, triggers the daemon login re-probe), everything else recoverable; `sessionID` is OPTIONAL on this one event — an unattributed error still counts as ours. A failed turn's idle settle emits status `idle` but NO `end_turn` stop. An idle absorbed before the busy acknowledgement logs a `console.warn` — the one trace if a server never emits `session.status` (the turn would then hang at `running`). -- **opencode RPC results resolve, they don't reject**: the generated client returns `{error}` for HTTP and network failures alike (`throwOnError` is never set) — every RPC result goes through `okOrThrow` or a failure silently reads as success (a permission reply that never landed, a prompt that never started). +- **opencode RPC errors resolve as `{error}`, they don't reject** (`throwOnError` is never set) — every RPC result goes through `okOrThrow` or a failure silently reads as success (a permission reply that never landed, a prompt that never started). Transport-level failures are the exception: fetch itself rejects on a dead/unreachable server (verified on SDK 1.18.3), so best-effort reads need their own catch. - **opencode permissions & questions** (CODE-136): opencode's default posture is allow-all — asks only fire when the user's own config (or a future preset) says `ask`. `permission.asked` → the shared `requestPermission` round-trip → `permission.reply({reply: 'once'|'always'|'reject'})`; `always` is persisted server-side as a saved rule. `question.asked` → `requestQuestion` → `question.reply({answers})` (one label array per question) or `question.reject`. An UNANSWERED ask gates the turn server-side forever, so a teardown-cancelled permission replies `reject` and a cancelled question calls `reject` — reply failures after a cancel are swallowed (the abort already discarded the ask). Asks cite their tool via `tool.callID`, but tool cards are announced under the PART id — `toolPartIdByCallId` re-joins them. A custom "Other" answer rides as an extra label: upstream `Question.reply` hands the answer arrays to the asking tool verbatim, with no validation against option labels (verified in anomalyco/opencode source). - **opencode history** (CODE-171, live-verified on 1.17.11): `list`/`read` are served by a daemon-shared, lazily-spawned, idle-reaped `opencode serve` (`opencode/history-server.ts`) — NOT a per-session server: HistoryService calls history on never-started factory instances. The manager spawns with a **neutral cwd** (`opencode-history` inside the resolved channel's state dir — `~/.linkcode/` or `~/.linkcode.development/`, CODE-460) because opencode indexes its cwd as the default workspace (a daemon launched from `$HOME` would index the whole home tree); `--port=0` does NOT auto-allocate (falls back to 4096) so the free port is found up front; shutdown escalates SIGTERM→SIGKILL. All `opencode serve` spawns (this manager and the per-session live server) go through `opencode/serve.ts` (CODE-76): binary from `agentRuntimeProber.resolveBinary('opencode')` (managed `agent:opencode` asset → detected user install, incl. `~/.opencode/bin`) with a bare-name PATH fallback for unprobed hosts — the SDK's `createOpencodeServer` is no longer used (it hard-codes bare-name PATH resolution, no cwd, no `windowsHide`). The neutral-cwd instance lists/reads sessions across every project with no `directory` scoping. `readHistory` full-fetches then slices at event level — the messages RPC's `limit` returns the LAST n messages, so it cannot page forward — and truncates at `Session.revert.messageID` (partial `partID` reverts keep everything rather than over-cut). Replay reuses the live part ids (`streamDelta`'s message keys, `toolCallFromPart` snapshots) so live and cold cards converge by id. `resumeHistory` is a native continue: adopt the existing session id (no `session.create`) and scope every call by the session's OWN directory, not the resume cwd. Fresh sessions defer `session-ref` until the first on-stream busy acknowledgement — announcing at create would seed the client against an empty transcript and the `uptoSeq` cut would swallow the first prompt (same deferral as codex). - **pi** — pure JS in-process (`createAgentSession()`), no binary spawn, unaffected by asar-spawn, not staged. The SDK import resolves store-first (CODE-219): `agentRuntimeProber.resolveEntry('pi')` — the managed npm-closure entry installed by `@linkcode/assets` — then the bare `import()` (dev/standalone). The SDK is deliberately a devDependency: `--prod` deploys (desktop staging, standalone daemon) carry no closure, so packaged hosts have only the store. The prober reports pi three-state: managed entry → `source:'managed'`, node_modules → `source:'sdk'`, neither → `missing` (onboarding offers the download). auth via `authStorage.setRuntimeApiKey(provider, apiKey)` overriding `~/.pi/agent/auth.json` + env; model `provider/rest`, falls back to `modelRegistry.getAvailable()[0]`, and the SDK-selected session model is reflected as `provider/id` after creation. `agent_end {willRetry:true}` is intermediate; only `agent_settled` finalizes the turn, sweeps unfinished tools, and maps the final assistant's success/error/aborted outcome. Pi has no approval callback: it advertises one fixed `bypassPermissions` policy so the UI exposes that all tools run without prompts, while policy changes still reject. diff --git a/packages/host/agent-adapter/src/__tests__/codex-commands.test.ts b/packages/host/agent-adapter/src/__tests__/codex-commands.test.ts index cc471214..84b8a68c 100644 --- a/packages/host/agent-adapter/src/__tests__/codex-commands.test.ts +++ b/packages/host/agent-adapter/src/__tests__/codex-commands.test.ts @@ -1,7 +1,15 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { AgentEvent, StartOptions } from '@linkcode/schema'; -import { describe, expect, it, vi } from 'vitest'; +import { afterAll, describe, expect, it, vi } from 'vitest'; import type { CodexServerHandle } from '../native/codex/adapter'; -import { CodexAdapter, codexSkillCommands } from '../native/codex/adapter'; +import { + CodexAdapter, + capSkillIconPayload, + codexSkillCommands, + skillIconDataUri, +} from '../native/codex/adapter'; import type { CodexAppServerOptions } from '../native/codex/app-server'; class FakeCodexServer { @@ -95,6 +103,49 @@ describe('CodexAdapter slash commands', () => { ]); }); + it('projects skill brand identity, preferring the small icon and validating the color', () => { + expect( + codexSkillCommands( + response( + { + name: 'documents', + description: 'Docs', + path: '/skills/documents/SKILL.md', + enabled: true, + interface: { + displayName: 'Documents', + brandColor: '#2563EB', + iconSmall: '/plugins/documents/assets/small.png', + iconLarge: '/plugins/documents/assets/large.png', + }, + }, + { + name: 'plain', + description: 'No brand', + path: '/skills/plain/SKILL.md', + enabled: true, + interface: { brandColor: 'blue', iconLarge: '/plugins/plain/assets/large.png' }, + }, + ), + ), + ).toEqual([ + { + name: 'documents', + description: 'Docs', + displayName: 'Documents', + brandColor: '#2563EB', + path: '/skills/documents/SKILL.md', + iconPath: '/plugins/documents/assets/small.png', + }, + { + name: 'plain', + description: 'No brand', + path: '/skills/plain/SKILL.md', + iconPath: '/plugins/plain/assets/large.png', + }, + ]); + }); + it('publishes /compact plus the skills/list catalog at session start', async () => { const adapter = new TestCodex( response( @@ -313,3 +364,96 @@ describe('CodexAdapter slash commands', () => { ]); }); }); + +describe('codex skill icons', () => { + const dirPromise = mkdtemp(join(tmpdir(), 'codex-skill-icon-')); + afterAll(async () => { + await rm(await dirPromise, { force: true, recursive: true }); + }); + + it('embeds a small icon file as a data URI and refuses non-icons', async () => { + const dir = await dirPromise; + const iconPath = join(dir, 'icon.png'); + const iconBytes = Buffer.from('89504E470D0A1A0A6D6F636B', 'hex'); + await writeFile(iconPath, iconBytes); + const oversizedPath = join(dir, 'oversized.png'); + await writeFile(oversizedPath, Buffer.alloc(33 * 1024, 1)); + + expect(await skillIconDataUri(iconPath)).toBe( + `data:image/png;base64,${iconBytes.toString('base64')}`, + ); + expect(await skillIconDataUri(oversizedPath)).toBeUndefined(); + expect(await skillIconDataUri(join(dir, 'missing.png'))).toBeUndefined(); + expect(await skillIconDataUri(join(dir, 'icon.bmp'))).toBeUndefined(); + }); + + it('rejects SVG icons carrying active content, keeping fragment-ref-only ones', async () => { + const dir = await dirPromise; + const svg = async (name: string, body: string) => { + const path = join(dir, name); + await writeFile(path, `${body}`); + return skillIconDataUri(path); + }; + + expect( + await svg('scripted.svg', ''), + ).toBeUndefined(); + expect(await svg('handler.svg', '')).toBeUndefined(); + expect(await svg('external.svg', '')).toBeUndefined(); + expect(await svg('foreign.svg', '
')).toBeUndefined(); + expect( + await svg( + 'clean.svg', + '', + ), + ).toBeDefined(); + }); + + it('publishes the embedded icon on the catalog while keeping paths private', async () => { + const dir = await dirPromise; + const iconPath = join(dir, 'documents.svg'); + await writeFile(iconPath, ''); + const adapter = new TestCodex( + response({ + name: 'documents', + description: 'Docs', + path: '/skills/documents/SKILL.md', + enabled: true, + interface: { displayName: 'Documents', brandColor: '#2563EB', iconSmall: iconPath }, + }), + ); + const events: AgentEvent[] = []; + adapter.onEvent((event) => events.push(event)); + + await adapter.start(start); + + const commands = catalog(events).at(-1)?.commands; + expect(commands?.find((command) => command.name === 'documents')).toEqual({ + name: 'documents', + description: 'Docs', + displayName: 'Documents', + brandColor: '#2563EB', + iconDataUri: `data:image/svg+xml;base64,${Buffer.from('').toString('base64')}`, + }); + expect(JSON.stringify(commands)).not.toContain(dir); + }); + + it('drops icons past the shared catalog budget while keeping smaller later ones', () => { + const dataUri = (length: number): string => `data:image/png;base64,${'A'.repeat(length)}`; + const commands = [ + { name: 'a', iconDataUri: dataUri(100) }, + { name: 'b', iconDataUri: dataUri(200) }, + { name: 'c', iconDataUri: dataUri(100) }, + { name: 'd' }, + ]; + + const capped = capSkillIconPayload(commands, dataUri(100).length * 2 + 50); + + expect(capped.map((command) => [command.name, command.iconDataUri !== undefined])).toEqual([ + ['a', true], + ['b', false], + ['c', true], + ['d', false], + ]); + }); +}); diff --git a/packages/host/agent-adapter/src/__tests__/opencode-history-adapter.test.ts b/packages/host/agent-adapter/src/__tests__/opencode-history-adapter.test.ts index f531fea7..a606e763 100644 --- a/packages/host/agent-adapter/src/__tests__/opencode-history-adapter.test.ts +++ b/packages/host/agent-adapter/src/__tests__/opencode-history-adapter.test.ts @@ -54,6 +54,18 @@ class HistoryTestAdapter extends OpenCodeAdapter { } } +function toolPart(id: string, tool: string) { + return { + id, + sessionID: 'ses-1', + messageID: 'msg-a1', + type: 'tool', + callID: `call-${id}`, + tool, + state: { status: 'completed', input: {}, output: 'ok', time: { start: 0, end: 1 } }, + }; +} + function makeSession(overrides: Partial = {}): Session { return { id: 'ses-1', @@ -199,6 +211,7 @@ describe('OpenCodeAdapter.readHistory', () => { ), messages: vi.fn(() => Promise.resolve({ data: [user, assistant, reverted] })), }, + config: { get: vi.fn(() => Promise.resolve({ data: {} })) }, }); const adapter = new HistoryTestAdapter(); @@ -220,6 +233,62 @@ describe('OpenCodeAdapter.readHistory', () => { }); expect(page2.cursor).toBeUndefined(); }); + + it('retitles MCP tools from the engine hint and config-declared servers alike', async () => { + const assistant = { + info: { id: 'msg-a1', sessionID: 'ses-1', role: 'assistant', time: { created: 20 } }, + parts: [ + // Engine-injected server (simulator endpoint) — absent from the session's config. + toolPart('prt-t1', 'linkcode-sim_sim_tap'), + // Config-declared server — resolved without any hint. + toolPart('prt-t2', 'notion_search_pages'), + toolPart('prt-t3', 'bash'), + ], + }; + sdkMock.createOpencodeClient = () => ({ + session: { + get: vi.fn(() => Promise.resolve({ data: makeSession() })), + messages: vi.fn(() => Promise.resolve({ data: [assistant] })), + }, + config: { get: vi.fn(() => Promise.resolve({ data: { mcp: { notion: {} } } })) }, + }); + + const result = await new HistoryTestAdapter().readHistory({ + historyId: 'ses-1' as AgentHistoryId, + mcpServerNames: ['linkcode-sim'], + }); + const titles = result.events.map((e) => + e.event.type === 'tool-call' ? e.event.toolCall.title : e.event.type, + ); + expect(titles).toEqual(['mcp__linkcode-sim__sim_tap', 'mcp__notion__search_pages', 'bash']); + }); + + it('keeps the transcript readable when the config read rejects (fetch throws on a dead server)', async () => { + const assistant = { + info: { id: 'msg-a1', sessionID: 'ses-1', role: 'assistant', time: { created: 20 } }, + parts: [ + toolPart('prt-t1', 'linkcode-sim_sim_tap'), + toolPart('prt-t2', 'notion_search_pages'), + ], + }; + sdkMock.createOpencodeClient = () => ({ + session: { + get: vi.fn(() => Promise.resolve({ data: makeSession() })), + messages: vi.fn(() => Promise.resolve({ data: [assistant] })), + }, + config: { get: vi.fn(() => Promise.reject(new TypeError('fetch failed'))) }, + }); + + const result = await new HistoryTestAdapter().readHistory({ + historyId: 'ses-1' as AgentHistoryId, + mcpServerNames: ['linkcode-sim'], + }); + const titles = result.events.map((e) => + e.event.type === 'tool-call' ? e.event.toolCall.title : e.event.type, + ); + // The engine hint still resolves; the config-declared server degrades to its raw title. + expect(titles).toEqual(['mcp__linkcode-sim__sim_tap', 'notion_search_pages']); + }); }); function makeLiveClient(resumedSession: Session | null) { @@ -234,6 +303,7 @@ function makeLiveClient(resumedSession: Session | null) { promptAsync: vi.fn(() => Promise.resolve({ data: null })), }, command: { list: vi.fn(() => Promise.resolve({ data: [] })) }, + config: { get: vi.fn(() => Promise.resolve({ data: {} })) }, event: { subscribe: vi.fn(() => Promise.resolve({ stream })) }, }; } diff --git a/packages/host/agent-adapter/src/__tests__/opencode-history.test.ts b/packages/host/agent-adapter/src/__tests__/opencode-history.test.ts index b9d6b1b1..10a24c00 100644 --- a/packages/host/agent-adapter/src/__tests__/opencode-history.test.ts +++ b/packages/host/agent-adapter/src/__tests__/opencode-history.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { filterRevertedMessages, mapOpencodeHistoryEvents, + opencodeMcpToolName, opencodeSessionToHistorySession, toolCallFromPart, } from '../native/opencode/history'; @@ -144,6 +145,48 @@ describe('toolCallFromPart', () => { part.state = { status: 'pending', input: {}, raw: '' }; expect(toolCallFromPart(part).status).toBe('pending'); }); + + it('retitles tools from known MCP servers to the shared mcp slug', () => { + const part = completedToolPart('prt-t1', 'msg-a1'); + part.tool = 'linear_list_issues'; + expect(toolCallFromPart(part, ['linear'])).toMatchObject({ + title: 'mcp__linear__list_issues', + }); + // Without the server list the flat provider name stays — same as before normalization. + expect(toolCallFromPart(part)).toMatchObject({ title: 'linear_list_issues' }); + }); +}); + +describe('opencodeMcpToolName', () => { + it('splits on the sanitized server prefix, longest match first', () => { + expect(opencodeMcpToolName('linear_list_issues', ['linear'])).toEqual({ + server: 'linear', + tool: 'list_issues', + }); + expect(opencodeMcpToolName('linear_beta_x', ['linear', 'linear_beta'])).toEqual({ + server: 'linear_beta', + tool: 'x', + }); + }); + + it('matches servers through opencode sanitization (non [A-Za-z0-9_-] chars become _)', () => { + expect(opencodeMcpToolName('claude_ai_Gmail_search', ['claude.ai Gmail'])).toEqual({ + server: 'claude.ai Gmail', + tool: 'search', + }); + // Hyphens survive sanitization, so a hyphenated server only matches its own exact prefix. + expect(opencodeMcpToolName('my-server_fetch', ['my-server'])).toEqual({ + server: 'my-server', + tool: 'fetch', + }); + }); + + it('never matches builtins, foreign prefixes, or empty tool remainders', () => { + expect(opencodeMcpToolName('bash', ['linear'])).toBeUndefined(); + expect(opencodeMcpToolName('github_search', ['git'])).toBeUndefined(); + expect(opencodeMcpToolName('linear_', ['linear'])).toBeUndefined(); + expect(opencodeMcpToolName('linear_list_issues', [])).toBeUndefined(); + }); }); describe('filterRevertedMessages', () => { diff --git a/packages/host/agent-adapter/src/__tests__/opencode.test.ts b/packages/host/agent-adapter/src/__tests__/opencode.test.ts index 06a505c0..fd362900 100644 --- a/packages/host/agent-adapter/src/__tests__/opencode.test.ts +++ b/packages/host/agent-adapter/src/__tests__/opencode.test.ts @@ -36,6 +36,9 @@ vi.mock('../native/opencode/serve', async (importOriginal) => ({ import { FakeEventStream } from './fake-event-stream'; +/** The config's `mcp` server entries `start()` reads — set BEFORE starting the adapter. */ +let mcpConfigFixture: Record = {}; + class FakeClient { readonly stream = new FakeEventStream(); subscribeError: Error | null = null; @@ -59,6 +62,9 @@ class FakeClient { readonly app = { agents: vi.fn(() => ({ data: [] as unknown[] })), }; + readonly config = { + get: vi.fn(() => ({ data: { mcp: mcpConfigFixture } })), + }; readonly provider = { list: vi.fn(() => ({ data: { all: [] as unknown[], default: {}, connected: [] as string[] }, @@ -82,6 +88,7 @@ sdkMock.createOpencode = () => { afterEach(() => { closeServer.mockClear(); + mcpConfigFixture = {}; }); async function makeAdapter(): Promise<{ adapter: OpenCodeAdapter; events: AgentEvent[] }> { @@ -319,6 +326,54 @@ describe('OpenCodeAdapter.consumeEvents', () => { ]); }); + it('normalizes MCP tool parts to the shared mcp slug from injected and configured servers', async () => { + mcpConfigFixture = { notion: { type: 'remote', url: 'http://127.0.0.1:7778/mcp' } }; + const adapter = new OpenCodeAdapter(); + const events: AgentEvent[] = []; + adapter.onEvent((e) => events.push(e)); + await adapter.start({ + kind: 'opencode', + cwd: '/tmp/repo', + mcpServers: [{ type: 'http', name: 'linear', url: 'http://127.0.0.1:7777/mcp' }], + }); + + const part = (id: string, tool: string) => ({ + id, + sessionID: 'sess-1', + messageID: 'msg-1', + type: 'tool' as const, + callID: `call-${id}`, + tool, + state: { status: 'running' as const, input: { limit: 50 }, time: { start: 0 } }, + }); + client.stream.push({ + id: 'e-mcp-injected', + type: 'message.part.updated', + properties: { sessionID: 'sess-1', time: 0, part: part('prt-mcp-1', 'linear_list_issues') }, + }); + // eslint-disable-next-line sukka/unicorn/prefer-single-call -- FakeEventStream.push accepts one provider event at a time + client.stream.push({ + id: 'e-mcp-configured', + type: 'message.part.updated', + properties: { sessionID: 'sess-1', time: 1, part: part('prt-mcp-2', 'notion_search_pages') }, + }); + // eslint-disable-next-line sukka/unicorn/prefer-single-call -- FakeEventStream.push accepts one provider event at a time + client.stream.push({ + id: 'e-builtin', + type: 'message.part.updated', + properties: { sessionID: 'sess-1', time: 2, part: part('prt-builtin', 'bash') }, + }); + + await vi.waitFor(() => { + expect(events.filter((event) => event.type === 'tool-call')).toHaveLength(3); + }); + const titles = events.reduce((all, event) => { + if (event.type === 'tool-call') all.push(event.toolCall.title); + return all; + }, []); + expect(titles).toEqual(['mcp__linear__list_issues', 'mcp__notion__search_pages', 'bash']); + }); + it('skips parts of a user message, so the prompt text is not replayed as agent output', async () => { const { events } = await makeAdapter(); diff --git a/packages/host/agent-adapter/src/adapter.ts b/packages/host/agent-adapter/src/adapter.ts index cdbacb36..746b4ed5 100644 --- a/packages/host/agent-adapter/src/adapter.ts +++ b/packages/host/agent-adapter/src/adapter.ts @@ -18,6 +18,14 @@ import type { Unsubscribe } from '@linkcode/transport'; export type AgentStartCatalogOptions = Partial>; +/** Wire read options plus engine-supplied context that never crosses the wire. */ +export type AgentHistoryReadContext = AgentHistoryReadOptions & { + /** MCP server names the engine injects at session start (managed connectors, the daemon's + * simulator endpoint). They exist only on a session's own live instance — never in the agent's + * config — so a cold read cannot recover them itself. */ + mcpServerNames?: readonly string[]; +}; + export interface BrowserToolExecuteResult { ok: boolean; value?: unknown; @@ -49,7 +57,7 @@ export interface AgentAdapter { /** List provider-local historical sessions, if supported. */ listHistory(opts?: AgentHistoryListOptions): Promise; /** Read a provider-local historical session as normalized events, if supported. */ - readHistory(opts: AgentHistoryReadOptions): Promise; + readHistory(opts: AgentHistoryReadContext): Promise; /** Start/resume a live adapter session from a provider-local history id, if supported. */ resumeHistory(opts: AgentHistoryResumeOptions, startOpts: StartOptions): Promise; /** Start this adapter on provider history forked before the cursor's historical prompt. */ diff --git a/packages/host/agent-adapter/src/base.ts b/packages/host/agent-adapter/src/base.ts index 9e7f6b3d..a8f579fc 100644 --- a/packages/host/agent-adapter/src/base.ts +++ b/packages/host/agent-adapter/src/base.ts @@ -7,7 +7,6 @@ import type { AgentHistoryId, AgentHistoryListOptions, AgentHistoryListResult, - AgentHistoryReadOptions, AgentHistoryReadResult, AgentHistoryResumeOptions, AgentInput, @@ -36,7 +35,7 @@ import { AGENT_INPUT_CAPABILITIES, textBlock } from '@linkcode/schema'; import type { Unsubscribe } from '@linkcode/transport'; import { Listeners } from '@linkcode/transport'; import { extractErrorMessage } from 'foxts/extract-error-message'; -import type { AgentAdapter, AgentStartCatalogOptions } from './adapter'; +import type { AgentAdapter, AgentHistoryReadContext, AgentStartCatalogOptions } from './adapter'; import { nextMessageId, nextRequestId } from './adapter'; type PermissionResolver = (outcome: PermissionOutcome) => void; @@ -110,7 +109,7 @@ export abstract class BaseAgentAdapter implements AgentAdapter { return Promise.resolve({ sessions: [] }); } - readHistory(_opts: AgentHistoryReadOptions): Promise { + readHistory(_opts: AgentHistoryReadContext): Promise { return Promise.reject(new Error(`${this.kind}: history read is not supported`)); } diff --git a/packages/host/agent-adapter/src/native/codex/adapter.ts b/packages/host/agent-adapter/src/native/codex/adapter.ts index 5d3252c0..4e7e93fd 100644 --- a/packages/host/agent-adapter/src/native/codex/adapter.ts +++ b/packages/host/agent-adapter/src/native/codex/adapter.ts @@ -1,4 +1,6 @@ +import { readFile, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; +import { extname } from 'node:path'; import type { AgentCommand, AgentHistoryBranchOptions, @@ -71,6 +73,8 @@ import { diffContentFromUnified } from './unified-diff'; interface CodexSkillCommand extends AgentCommand { path: string; + /** Brand icon file inside the skill/plugin package — read and embedded at publish time. */ + iconPath?: string; } type CodexTurnInput = @@ -87,6 +91,8 @@ function resolveCodexEnvironment(cwd?: string): Promise { return resolveAgentShellEnvironment(cwd ?? homedir()); } +const BRAND_COLOR_RE = /^#[0-9A-F]{6}$/i; + /** Map the app-server's `skills/list` response onto the normalized command catalog: only enabled * skills are invokable, and duplicate names resolve to the first provider result, like the TUI's * name-based mention lookup. */ @@ -101,19 +107,80 @@ export function codexSkillCommands(response: unknown): CodexSkillCommand[] { const path = stringField(skill, 'path'); if (!name || !path || commands.has(name)) continue; const interfaceMetadata = recordField(skill, 'interface'); + const brandColor = interfaceMetadata && stringField(interfaceMetadata, 'brandColor'); commands.set(name, { name, description: stringField(skill, 'description') ?? (interfaceMetadata && stringField(interfaceMetadata, 'shortDescription')) ?? stringField(skill, 'shortDescription'), + displayName: interfaceMetadata && stringField(interfaceMetadata, 'displayName'), + brandColor: brandColor && BRAND_COLOR_RE.test(brandColor) ? brandColor : undefined, path, + iconPath: + (interfaceMetadata && stringField(interfaceMetadata, 'iconSmall')) ?? + (interfaceMetadata && stringField(interfaceMetadata, 'iconLarge')), }); } } return [...commands.values()].sort((a, b) => a.name.localeCompare(b.name)); } +/** Composer icons render at chip size; anything bigger than this is not an icon. */ +const SKILL_ICON_MAX_BYTES = 32 * 1024; + +const SKILL_ICON_MIME: Record = { + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', +}; + +/** SVG that would execute or fetch if ever parsed as a document (scripts, foreignObject, event + * handlers, javascript: URLs, non-fragment refs) — inert inside ``, rejected for depth. */ +const SVG_ACTIVE_CONTENT_RE = + /<\s*(?:script|foreignObject)\b|\bon[a-z]+\s*=|javascript:|\b(?:xlink:)?href\s*=\s*["'](?!#)/i; + +/** Embed a skill's icon file as a `data:image/*` URI, or nothing when the file is missing, + * unreasonably large, not a known image type, or an SVG carrying active content. The path comes + * from the user's own codex install; failures degrade to the glyph fallback, never to an error. */ +export async function skillIconDataUri(iconPath: string): Promise { + const mime = SKILL_ICON_MIME[extname(iconPath).toLowerCase()]; + if (!mime) return undefined; + try { + const info = await stat(iconPath); + if (!info.isFile() || info.size === 0 || info.size > SKILL_ICON_MAX_BYTES) return undefined; + const data = await readFile(iconPath); + if (mime === 'image/svg+xml' && SVG_ACTIVE_CONTENT_RE.test(data.toString('utf8'))) { + return undefined; + } + return `data:${mime};base64,${data.toString('base64')}`; + } catch { + return undefined; + } +} + +/** One catalog frame is engine-cached and replayed on every attach; bound its total embedded + * icon payload so a huge plugin catalog cannot outgrow transport reassembly buffers. */ +const SKILL_ICON_TOTAL_MAX_LENGTH = 256 * 1024; + +/** Drop icons that no longer fit the shared budget (catalog order — sorted by name); dropped + * ones fall back to the brandColor initial chip. */ +export function capSkillIconPayload( + commands: AgentCommand[], + budget = SKILL_ICON_TOTAL_MAX_LENGTH, +): AgentCommand[] { + let remaining = budget; + return commands.map((command) => { + if (command.iconDataUri === undefined) return command; + if (command.iconDataUri.length > remaining) return { ...command, iconDataUri: undefined }; + remaining -= command.iconDataUri.length; + return command; + }); +} + interface CodexModelCatalog { defaultModel: string | undefined; models: AgentModelOption[]; @@ -931,9 +998,17 @@ export class CodexAdapter extends BaseAgentAdapter { const skills = codexSkillCommands(response).filter( (skill) => skill.name !== COMPACT_COMMAND.name, ); + const catalog = await Promise.all( + skills.map(async ({ path: _path, iconPath, ...command }) => ({ + ...command, + iconDataUri: iconPath === undefined ? undefined : await skillIconDataUri(iconPath), + })), + ); + // The icon reads awaited — re-check that a newer refresh or server didn't win meanwhile. + if (this.server !== server || generation !== this.skillsRefreshGeneration) return; this.skillCommands.clear(); for (const skill of skills) this.skillCommands.set(skill.name, skill); - this.emitCommands([COMPACT_COMMAND, ...skills.map(({ path: _path, ...command }) => command)]); + this.emitCommands([COMPACT_COMMAND, ...capSkillIconPayload(catalog)]); } catch { if (this.server === server && generation === this.skillsRefreshGeneration) { this.skillCommands.clear(); diff --git a/packages/host/agent-adapter/src/native/opencode/adapter.ts b/packages/host/agent-adapter/src/native/opencode/adapter.ts index 4d91fc8b..a6a8d5bf 100644 --- a/packages/host/agent-adapter/src/native/opencode/adapter.ts +++ b/packages/host/agent-adapter/src/native/opencode/adapter.ts @@ -7,7 +7,6 @@ import type { AgentHistoryCapabilities, AgentHistoryListOptions, AgentHistoryListResult, - AgentHistoryReadOptions, AgentHistoryReadResult, AgentHistoryResumeOptions, AgentModelOption, @@ -32,7 +31,7 @@ import { invariant, nullthrow } from 'foxts/guard'; import { isObjectEmpty } from 'foxts/is-object-empty'; import { falseFn } from 'foxts/noop'; import { wait } from 'foxts/wait'; -import type { AgentStartCatalogOptions } from '../../adapter'; +import type { AgentHistoryReadContext, AgentStartCatalogOptions } from '../../adapter'; import { AUTH_FAILED_ERROR_CODE, nextToolCallId } from '../../adapter'; import { BaseAgentAdapter } from '../../base'; import { readAgentCredential } from '../../credential'; @@ -47,6 +46,7 @@ import { import { filterRevertedMessages, mapOpencodeHistoryEvents, + opencodeMcpTitle, opencodeSessionToHistorySession, toolCallFromPart, } from './history'; @@ -121,9 +121,9 @@ async function canonicalDirectory(cwd: string): Promise { } } -/** The generated client resolves with `{error}` on HTTP and network failures alike (nothing here - * passes `throwOnError`), so every RPC result must be checked — an unchecked failure silently - * reads as success. Throws with the error detail when the result carries one. */ +/** The generated client resolves non-2xx responses as `{error}` (nothing here passes + * `throwOnError`), so every RPC result must be checked — an unchecked failure silently reads as + * success. Transport-level failures are the exception: fetch itself rejects on a dead server. */ function okOrThrow(result: T, context: string): T { if (result.error === undefined) return result; let detail: string; @@ -259,6 +259,10 @@ export class OpenCodeAdapter extends BaseAgentAdapter { /** Tool part id by provider `callID`: asks cite tools via `tool.callID` but the card was * announced under the PART id — this map re-joins them. Cleared at each turn settle. */ private readonly toolPartIdByCallId = new Map(); + /** MCP server names this session can call (injected StartOptions servers plus the instance's + * own configured ones) — the only key that splits opencode's flat `server_tool` MCP tool + * names back into the shared `mcp____` slug (history.ts). */ + private mcpServerNames: readonly string[] = []; /** Message ids reported with `role: 'user'` — their parts must be skipped: the server streams * `message.part.updated` for the user's own prompt text too (observed live on 1.17.11), and * replaying it would double-render the prompt as an agent bubble. Cleared at each turn settle. */ @@ -376,6 +380,7 @@ export class OpenCodeAdapter extends BaseAgentAdapter { this.fetchModelCatalog(), this.fetchCommandCatalog(), this.fetchAgentCatalog(opts.approvalPolicyId ?? resumedAgent), + this.collectMcpServerNames(opts.mcpServers), ]); if (opts.approvalPolicyId && this.currentAgent && this.currentAgent !== opts.approvalPolicyId) { this.emitError( @@ -687,7 +692,24 @@ export class OpenCodeAdapter extends BaseAgentAdapter { }; } - override async readHistory(opts: AgentHistoryReadOptions): Promise { + /** Resolve the session's MCP server names: injected StartOptions servers plus the merged + * config's `mcp` keys — a pure config read. Deliberately NOT `mcp.status`, whose lazy init + * connects every configured server (spawning stdio children, dialing dead remotes for up to + * the 30s-per-transport timeout). Best-effort: a failed read keeps the injected set. */ + private async collectMcpServerNames(injected: StartOptions['mcpServers']): Promise { + const names = new Set((injected ?? []).map((server) => server.name)); + this.mcpServerNames = [...names]; + if (!this.client) return; + try { + const config = await this.client.config.get({ directory: this.directory }); + for (const name of Object.keys(config.data?.mcp ?? {})) names.add(name); + this.mcpServerNames = [...names]; + } catch { + // fetch rejects on a dead server — retitling is never worth failing the session for. + } + } + + override async readHistory(opts: AgentHistoryReadContext): Promise { const offset = cursorOffset(opts.cursor); const limit = boundedLimit(opts.limit, 1000, 1000); const { session, events } = await this.withHistoryClient(async (client) => { @@ -702,11 +724,24 @@ export class OpenCodeAdapter extends BaseAgentAdapter { throw new Error(`opencode: history '${opts.historyId}' was not found`); } okOrThrow(messages, 'opencode: session.messages'); + // Best-effort MCP server names so replayed MCP tool titles converge with live ones — a + // pure config read, never `mcp.status` (its lazy init would spawn/dial every configured + // server from the shared history instance). Config-declared servers resolve, including + // disabled ones; engine-injected servers exist only on a session's own live instance, so + // the caller's `mcpServerNames` hint is the only way their calls resolve here. + const names = new Set(opts.mcpServerNames); + try { + const config = await client.config.get({ directory: got.data.directory }); + for (const name of Object.keys(config.data?.mcp ?? {})) names.add(name); + } catch { + // fetch rejects on a dead server — the read proceeds with the caller's hint set. + } return { session: opencodeSessionToHistorySession(got.data), events: mapOpencodeHistoryEvents( opts.historyId, filterRevertedMessages(messages.data ?? [], got.data.revert), + [...names], ), }; }); @@ -1045,11 +1080,15 @@ export class OpenCodeAdapter extends BaseAgentAdapter { ? { type: 'command' as const, command, cwd: this.directory, toolCallId: linkedToolCallId } : undefined; const toolCallId = linkedToolCallId ?? (commandSubject ? undefined : nextToolCallId()); + // Asks cite MCP tools by their flat provider name — retitle like the tool cards so the ask + // and the call it gates wear the same identity. + const permissionTitle = + opencodeMcpTitle(props.permission, this.mcpServerNames) ?? props.permission; if (!linkedToolCallId && toolCallId) { this.emitTool({ toolCallId, - title: props.permission, - kind: toolKindFromName(props.permission), + title: permissionTitle, + kind: toolKindFromName(permissionTitle), status: 'in_progress', rawInput, locations: locationsFromToolInput(rawInput), @@ -1057,7 +1096,7 @@ export class OpenCodeAdapter extends BaseAgentAdapter { } const outcome = await this.requestPermission( { - title: props.permission, + title: permissionTitle, subject: commandSubject ?? { type: 'tool-call', toolCallId: toolCallId ?? nextToolCallId(), @@ -1181,7 +1220,7 @@ export class OpenCodeAdapter extends BaseAgentAdapter { this.toolPartIdByCallId.set(part.callID, part.id); // History emits one full snapshot. Live terminal output appends separately, so repeated // cumulative part updates do not retransmit an ever-growing content array. - const toolCall = toolCallFromPart(part); + const toolCall = toolCallFromPart(part, this.mcpServerNames); if (toolCall.status === 'completed' || toolCall.status === 'failed') { for (const content of toolCall.content) this.appendToolContent(part.id, content); this.emitTool({ ...toolCall, content: undefined }); diff --git a/packages/host/agent-adapter/src/native/opencode/history.ts b/packages/host/agent-adapter/src/native/opencode/history.ts index 7728d373..2addb2e3 100644 --- a/packages/host/agent-adapter/src/native/opencode/history.ts +++ b/packages/host/agent-adapter/src/native/opencode/history.ts @@ -51,13 +51,48 @@ export function toolStateContent(state: ToolPartState): ToolCallContent[] { return []; } +const OPENCODE_TOOL_NAME_SANITIZE_RE = /[^\w-]/g; + +/** opencode's model-facing MCP tool name is `sanitize(server)_sanitize(tool)` — a flat single- + * underscore join with every char outside [A-Za-z0-9_-] mapped to `_`, and no server field on + * the part (anomalyco/opencode 1.18.15 `McpCatalog.toolName`). The join is only reversible + * against the configured server names; the longest sanitized prefix wins. The flat namespace is + * shared with underscore builtins (`apply_patch`) and custom `_` tools, so a + * server named like such a prefix retitles them — inherent to the join, cosmetic only. */ +export function opencodeMcpToolName( + tool: string, + mcpServers: readonly string[], +): { server: string; tool: string } | undefined { + let match: { server: string; tool: string } | undefined; + let matchedLength = 0; + for (const server of mcpServers) { + const prefix = `${server.replace(OPENCODE_TOOL_NAME_SANITIZE_RE, '_')}_`; + if (prefix.length <= matchedLength || tool.length <= prefix.length) continue; + if (tool.startsWith(prefix)) { + match = { server, tool: tool.slice(prefix.length) }; + matchedLength = prefix.length; + } + } + return match; +} + +/** The shared cross-agent `mcp____` title slug for a flat opencode tool name, + * when it resolves to a known server. */ +export function opencodeMcpTitle(tool: string, mcpServers: readonly string[]): string | undefined { + const mcp = opencodeMcpToolName(tool, mcpServers); + return mcp && `mcp__${mcp.server}__${mcp.tool}`; +} + /** A tool part as the full ToolCall snapshot. Live stream (`emitTool`) and history replay share - * this one mapping, keyed by the part id, so cold and live tool cards converge by id. */ -export function toolCallFromPart(part: ToolPart): ToolCall { + * this one mapping, keyed by the part id, so cold and live tool cards converge by id. A tool + * from a known MCP server takes the shared `mcp____` title slug, like claude-code + * and codex, so server context, brand glyphs, and integration groups light up downstream. */ +export function toolCallFromPart(part: ToolPart, mcpServers: readonly string[] = []): ToolCall { + const title = opencodeMcpTitle(part.tool, mcpServers) ?? part.tool; return { toolCallId: part.id, - title: part.tool, - kind: toolKindFromName(part.tool), + title, + kind: toolKindFromName(title), status: mapOpencodeToolStatus(part.state.status), content: toolStateContent(part.state), rawInput: part.state.input, @@ -107,6 +142,7 @@ export function filterRevertedMessages( export function mapOpencodeHistoryEvents( historyId: AgentHistoryId, messages: OpencodeMessageWithParts[], + mcpServers: readonly string[] = [], ): AgentHistoryEvent[] { const events: AgentHistoryEvent[] = []; for (const { info, parts } of messages) { @@ -149,7 +185,7 @@ export function mapOpencodeHistoryEvents( historyId, itemId: part.id, ts, - event: { type: 'tool-call', toolCall: toolCallFromPart(part) }, + event: { type: 'tool-call', toolCall: toolCallFromPart(part, mcpServers) }, }); break; default: diff --git a/packages/host/engine/src/__tests__/fixtures/history-adapter.ts b/packages/host/engine/src/__tests__/fixtures/history-adapter.ts index e57a90ca..ae4ea69f 100644 --- a/packages/host/engine/src/__tests__/fixtures/history-adapter.ts +++ b/packages/host/engine/src/__tests__/fixtures/history-adapter.ts @@ -1,4 +1,4 @@ -import type { AdapterFactory } from '@linkcode/agent-adapter'; +import type { AdapterFactory, AgentHistoryReadContext } from '@linkcode/agent-adapter'; import { BaseAgentAdapter } from '@linkcode/agent-adapter'; import type { AgentHistoryCapabilities, @@ -6,7 +6,6 @@ import type { AgentHistoryId, AgentHistoryListOptions, AgentHistoryListResult, - AgentHistoryReadOptions, AgentHistoryReadResult, AgentHistoryResumeOptions, AgentKind, @@ -19,7 +18,7 @@ export interface FakeHistoryState { listCalls: number; readCalls: number; resumeCalls: number; - lastReadOptions?: AgentHistoryReadOptions; + lastReadOptions?: AgentHistoryReadContext; events?: AgentHistoryEvent[]; } @@ -81,7 +80,7 @@ export class FakeHistoryAdapter extends BaseAgentAdapter { return Promise.resolve({ sessions: [historySession(this.state.listCalls)] }); } - override readHistory(opts: AgentHistoryReadOptions): Promise { + override readHistory(opts: AgentHistoryReadContext): Promise { this.state.readCalls += 1; this.state.lastReadOptions = opts; return Promise.resolve({ diff --git a/packages/host/engine/src/__tests__/history-service.test.ts b/packages/host/engine/src/__tests__/history-service.test.ts index 04f85431..39c60660 100644 --- a/packages/host/engine/src/__tests__/history-service.test.ts +++ b/packages/host/engine/src/__tests__/history-service.test.ts @@ -40,6 +40,20 @@ describe('HistoryService', () => { expect(state.readCalls).toBe(2); }); + it('hands the injected MCP server names to cold reads', async () => { + const state: FakeHistoryState = { listCalls: 0, readCalls: 0, resumeCalls: 0 }; + const service = new HistoryService(fakeHistoryFactory(state), { + ttlMs: 60000, + injectedMcpServerNames: (kind) => (kind === 'opencode' ? ['linkcode-sim'] : []), + }); + + await Effect.runPromise(service.read('opencode', { historyId })); + expect(state.lastReadOptions?.mcpServerNames).toEqual(['linkcode-sim']); + + await Effect.runPromise(service.read('codex', { historyId })); + expect(state.lastReadOptions?.mcpServerNames).toBeUndefined(); + }); + it('removes injected resource context from provider history', async () => { const state = { listCalls: 0, diff --git a/packages/host/engine/src/__tests__/start-options-mcp.test.ts b/packages/host/engine/src/__tests__/start-options-mcp.test.ts index 74e7114f..ccd591c0 100644 --- a/packages/host/engine/src/__tests__/start-options-mcp.test.ts +++ b/packages/host/engine/src/__tests__/start-options-mcp.test.ts @@ -156,6 +156,19 @@ describe('simulator MCP injection at session start', () => { }); }); +describe('injectedMcpServerNames', () => { + it('names the servers resolve would inject, and nothing for MCP-incapable kinds', () => { + const resolver = new SessionStartOptionsResolver( + new InMemoryProviderConfigStore(), + undefined, + provider(ENDPOINT), + customService(customEntry('github'), customEntry('disabled-one', false)), + ); + expect(resolver.injectedMcpServerNames('opencode')).toEqual(['github', 'linkcode-sim']); + expect(resolver.injectedMcpServerNames('pi')).toEqual([]); + }); +}); + describe('account binding at session start', () => { function storeWith(account: Account, agent: AgentKind): InMemoryProviderConfigStore { const store = new InMemoryProviderConfigStore(); diff --git a/packages/host/engine/src/engine.ts b/packages/host/engine/src/engine.ts index b10921e6..b38dd8d7 100644 --- a/packages/host/engine/src/engine.ts +++ b/packages/host/engine/src/engine.ts @@ -100,8 +100,18 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( deps.stateDir, fileHost, ); - const history = new HistoryService(factory); const plugins = new PluginService(deps.pluginFactory ?? createPluginProviderAdapter); + const translator = deps.translator; + const startOptions = new SessionStartOptionsResolver( + providerStore, + translator, + deps.simulatorMcp, + customMcp, + plugins, + ); + const history = new HistoryService(factory, { + injectedMcpServerNames: (kind) => startOptions.injectedMcpServerNames(kind), + }); const runtimes = yield* AgentRuntimeService.make( { initial: deps.agentRuntimes, @@ -172,14 +182,6 @@ export const createEngineRuntime = Effect.fn('Engine.create')(function* ( const artifacts = new ArtifactHostService(routes); const artifactRequests = new ArtifactRequestHandler(transport, artifacts, responder); const resourceRequests = new ResourceRequestHandler(transport, resources, responder); - const translator = deps.translator; - const startOptions = new SessionStartOptionsResolver( - providerStore, - translator, - deps.simulatorMcp, - customMcp, - plugins, - ); const sessionLifecycle = new SessionLifecycleService( sessions, records, diff --git a/packages/host/engine/src/session/history-service.ts b/packages/host/engine/src/session/history-service.ts index 4fa44c48..4fc0b80d 100644 --- a/packages/host/engine/src/session/history-service.ts +++ b/packages/host/engine/src/session/history-service.ts @@ -31,6 +31,9 @@ export type HistoryReadOptions = AgentHistoryReadOptions & { export interface HistoryServiceOptions { ttlMs?: number; now?: () => number; + /** MCP server names the engine injects at session start (start-options-resolver) — passed to + * cold reads so replayed calls to injected servers resolve like config-declared ones. */ + injectedMcpServerNames?: (kind: AgentKind) => readonly string[]; } interface ListCacheEntry { @@ -53,6 +56,7 @@ export class HistoryService { private readonly historyCwdById = new Map(); private readonly ttlMs: number; private readonly now: () => number; + private readonly injectedMcpServerNames?: (kind: AgentKind) => readonly string[]; constructor( private readonly factory: AdapterFactory, @@ -60,6 +64,7 @@ export class HistoryService { ) { this.ttlMs = opts.ttlMs ?? 30000; this.now = opts.now ?? Date.now; + this.injectedMcpServerNames = opts.injectedMcpServerNames; } list( @@ -132,8 +137,13 @@ export class HistoryService { }), ); } + const mcpServerNames = this.injectedMcpServerNames?.(kind); + const readContext = { + ...(cwd && { cwd }), + ...(mcpServerNames?.length && { mcpServerNames }), + }; return agentHistoryOperation('history.read', 'Failed to read agent history', () => - adapter.readHistory({ historyId: opts.historyId, ...(cwd && { cwd }), limit: 1000 }), + adapter.readHistory({ historyId: opts.historyId, ...readContext, limit: 1000 }), ).pipe( Effect.map(sanitizeHistoryResult), Effect.flatMap((fullResult) => { @@ -150,7 +160,7 @@ export class HistoryService { return Effect.succeed(sliceEventCache(entry, offset, limit)); } return agentHistoryOperation('history.read', 'Failed to read agent history', () => - adapter.readHistory({ ...stripForceRefresh(opts), ...(cwd && { cwd }) }), + adapter.readHistory({ ...stripForceRefresh(opts), ...readContext }), ).pipe(Effect.map(sanitizeHistoryResult)); }), ); diff --git a/packages/host/engine/src/session/start-options-resolver.ts b/packages/host/engine/src/session/start-options-resolver.ts index 91566d02..d3b07c8f 100644 --- a/packages/host/engine/src/session/start-options-resolver.ts +++ b/packages/host/engine/src/session/start-options-resolver.ts @@ -1,4 +1,4 @@ -import type { McpWarning, SessionId, StartOptions } from '@linkcode/schema'; +import type { AgentKind, McpWarning, SessionId, StartOptions } from '@linkcode/schema'; import { Effect } from 'effect'; import { isObjectEmpty } from 'foxts/is-object-empty'; import type { CustomMcpServerService } from '../agent/custom-mcp-service'; @@ -9,7 +9,7 @@ import { translationUpstream, withTranslatorEndpoint } from '../agent/translator import { OperationError, RequestError } from '../failure'; import type { PluginService } from '../plugin/service'; import type { SimulatorMcpProvider } from '../simulator/mcp'; -import { MCP_CAPABLE_AGENT_KINDS } from './mcp-capability'; +import { MCP_CAPABLE_AGENT_KINDS, SIMULATOR_MCP_SERVER_NAME } from './mcp-capability'; export interface ResolvedStartOptions { readonly options: StartOptions; @@ -80,6 +80,16 @@ export class SessionStartOptionsResolver { }); } + /** The server names `resolve` would inject for this kind, as a hint for cold history reads: + * injected servers never appear in the agent's own config, so a replayed MCP call cannot + * resolve its server without this set. */ + injectedMcpServerNames(kind: AgentKind): string[] { + if (!MCP_CAPABLE_AGENT_KINDS.has(kind)) return []; + const names = (this.customMcp?.listEnabled() ?? []).map((entry) => entry.server.name); + if (this.simulatorMcp) names.push(SIMULATOR_MCP_SERVER_NAME); + return names; + } + /** Fold enabled custom MCP servers into the session's server list, warning instead of * silently dropping: unsupported agent kinds and name collisions are user-visible facts. */ private withCustomMcpServers(options: StartOptions): Effect.Effect { diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts index c91df4f0..2cf65902 100644 --- a/packages/presentation/i18n/src/locales/en.ts +++ b/packages/presentation/i18n/src/locales/en.ts @@ -188,10 +188,12 @@ export const en = { think: 'Thinking', fetch: 'Fetching', other: 'Using an integration', + integrationBrand: 'Using {brand}', }, settled: { files: 'Made {count, plural, one {a file change} other {# file changes}}', integration: 'Used an integration {count, plural, one {once} other {# times}}', + integrationBrand: 'Used {brand} {count, plural, one {once} other {# times}}', command: 'Ran {count, plural, one {a command} other {# commands}}', explore: 'Explored {count, plural, one {once} other {# times}}', thinking: 'Thought', @@ -199,6 +201,7 @@ export const en = { settledMany: { files: 'Changed files', integration: 'Used integrations', + integrationBrand: 'Used {brand} repeatedly', command: 'Ran commands', explore: 'Explored repeatedly', }, diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts index 701d1cd6..adf5e1c7 100644 --- a/packages/presentation/i18n/src/locales/zh-cn.ts +++ b/packages/presentation/i18n/src/locales/zh-cn.ts @@ -184,10 +184,12 @@ export const zhCN = { think: '正在思考', fetch: '正在抓取', other: '正在调用集成', + integrationBrand: '正在调用 {brand}', }, settled: { files: '{count, plural, =1 {进行了一项文件改动} other {进行了 # 项文件改动}}', integration: '{count, plural, =1 {调用了一次集成} other {调用了 # 次集成}}', + integrationBrand: '{count, plural, =1 {调用了一次 {brand}} other {调用了 # 次 {brand}}}', command: '{count, plural, =1 {执行了一条命令} other {执行了 # 条命令}}', explore: '{count, plural, =1 {探索了一次} other {探索了 # 次}}', thinking: '思考', @@ -195,6 +197,7 @@ export const zhCN = { settledMany: { files: '进行了多项文件改动', integration: '调用了多个集成', + integrationBrand: '多次调用 {brand}', command: '执行了多条命令', explore: '进行了多次探索', }, diff --git a/packages/presentation/ui/src/__tests__/activity-groups.test.ts b/packages/presentation/ui/src/__tests__/activity-groups.test.ts index 7ac1c54d..0f5c9b3f 100644 --- a/packages/presentation/ui/src/__tests__/activity-groups.test.ts +++ b/packages/presentation/ui/src/__tests__/activity-groups.test.ts @@ -136,6 +136,53 @@ describe('groupTimeline', () => { expect(groupTimeline(items)).toEqual([{ type: 'item', item: items[0] }]); }); + it('isolates branded integration calls into dedicated per-brand runs', () => { + const items = [ + tool('read'), + tool('search'), + tool('other', { title: 'mcp__linear__get_issue' }), + tool('other', { title: 'mcp__linear__save_issue' }), + tool('other', { title: 'mcp__slack__send_message' }), + tool('other'), + tool('execute'), + ]; + const entries = groupTimeline(items); + + expect(entries).toEqual([ + { type: 'run', id: `run-${items[0].id}`, items: [items[0], items[1]] }, + { type: 'run', id: `run-${items[2].id}`, items: [items[2], items[3]] }, + // A lone branded call stays an ordinary row — its own header already wears the brand. + { type: 'item', item: items[4] }, + { type: 'run', id: `run-${items[5].id}`, items: [items[5], items[6]] }, + ]); + }); + + it('glues thinking into a brand run instead of splitting it', () => { + const items = [ + tool('other', { title: 'mcp__linear__get_issue' }), + reasoning(), + tool('other', { title: 'mcp__linear__save_issue' }), + tool('think'), + tool('other', { title: 'mcp__linear__close_issue' }), + ]; + + expect(groupTimeline(items)).toEqual([{ type: 'run', id: `run-${items[0].id}`, items }]); + }); + + it('lets leading thinking open the run the next keyed item defines', () => { + const lead = reasoning(); + const branded = [ + tool('other', { title: 'mcp__linear__get_issue' }), + tool('other', { title: 'mcp__linear__save_issue' }), + ]; + const generic = tool('read'); + + expect(groupTimeline([lead, ...branded, generic])).toEqual([ + { type: 'run', id: `run-${lead.id}`, items: [lead, ...branded] }, + { type: 'item', item: generic }, + ]); + }); + it.each(['user', 'assistant'] as const)('splits runs on %s messages', (role) => { const first = tool('read'); const narration = message(role); diff --git a/packages/presentation/ui/src/__tests__/activity-summary.test.ts b/packages/presentation/ui/src/__tests__/activity-summary.test.ts index 813de9b0..e672d618 100644 --- a/packages/presentation/ui/src/__tests__/activity-summary.test.ts +++ b/packages/presentation/ui/src/__tests__/activity-summary.test.ts @@ -2,6 +2,7 @@ import type { ToolCall } from '@linkcode/schema'; import { describe, expect, it } from 'vitest'; import type { ActivityRunItem } from '../chat/activity-groups'; import { + activityRunBrand, activityRunCurrentDescriptor, settledActivityRunDescriptor, } from '../chat/activity-summary'; @@ -188,3 +189,33 @@ describe('activityRunCurrentDescriptor', () => { expect(JSON.stringify(descriptor)).not.toMatch(RE_SENSITIVE_ACTIVITY_DETAIL); }); }); + +describe('activityRunBrand', () => { + it('wears the single distinct brand and stays generic for mixed or unbranded runs', () => { + expect( + activityRunBrand([ + tool('other', { title: 'mcp__linear__get_issue' }), + tool('read'), + tool('other', { title: 'mcp__linear__save_issue' }), + ]), + ).toBe('linear'); + expect( + activityRunBrand([ + tool('other', { title: 'mcp__linear__get_issue' }), + tool('other', { title: 'mcp__slack__send_message' }), + ]), + ).toBeUndefined(); + expect( + activityRunBrand([tool('other', { title: 'mcp__f5fcc7d5-d616__get_issue' }), tool('read')]), + ).toBeUndefined(); + }); + + it('lets a running branded call win over a mixed settled run', () => { + expect( + activityRunBrand([ + tool('other', { title: 'mcp__linear__get_issue' }), + tool('other', { title: 'mcp__slack__send_message', status: 'in_progress' }), + ]), + ).toBe('slack'); + }); +}); diff --git a/packages/presentation/ui/src/__tests__/composer-command.test.ts b/packages/presentation/ui/src/__tests__/composer-command.test.ts index 259aa3b8..4ca43e6c 100644 --- a/packages/presentation/ui/src/__tests__/composer-command.test.ts +++ b/packages/presentation/ui/src/__tests__/composer-command.test.ts @@ -46,4 +46,28 @@ describe('buildComposerCommandGroups slash catalog', () => { it('has no slash results when the catalog is empty', () => { expect(slashGroups([])).toEqual([]); }); + + it('shows the display name beside the description and matches queries against it', () => { + const branded: AgentCommand[] = [ + { + name: 'documents', + description: 'Create and edit files', + displayName: 'Documents', + brandColor: '#2563EB', + }, + { name: 'sync-linear', displayName: 'Linear' }, + ]; + + const [group] = slashGroups(branded); + const commandEntries = group.items.filter((item) => item.kind === 'command'); + expect(commandEntries[0].hint).toBe('Documents · Create and edit files'); + expect(commandEntries[1].hint).toBe('Linear'); + + const [filtered] = slashGroups(branded, 'linear'); + const values = filtered.items.reduce((names, item) => { + if (item.kind === 'command') names.push(item.value); + return names; + }, []); + expect(values).toEqual(['sync-linear']); + }); }); diff --git a/packages/presentation/ui/src/chat/__tests__/activity-run.test.tsx b/packages/presentation/ui/src/chat/__tests__/activity-run.test.tsx index 8f951ada..21dac32d 100644 --- a/packages/presentation/ui/src/chat/__tests__/activity-run.test.tsx +++ b/packages/presentation/ui/src/chat/__tests__/activity-run.test.tsx @@ -35,6 +35,11 @@ function translateKey(key: string, values?: Record): string { if (key === 'settled.integration') { return count === 1 ? 'Used an integration once' : `Used an integration ${count} times`; } + if (key === 'settled.integrationBrand') { + const brand = String(values?.brand); + return count === 1 ? `Used ${brand} once` : `Used ${brand} ${count} times`; + } + if (key === 'running.integrationBrand') return `Using ${String(values?.brand)}`; if (key === 'thoughtDuration') return `Thought for ${String(values?.seconds)} seconds`; return labels[key] ?? key; } @@ -569,4 +574,48 @@ describe('ActivityRun', () => { expect(header.textContent).not.toContain('secret-command'); if (count > 10) expect(header.textContent).not.toContain(String(count)); }); + + it('names and brands a dedicated integration run, but never tints the brand red', () => { + const healthy = activityRun([ + simpleTool('linear-1', 'other', 'mcp__linear__get_issue'), + simpleTool('linear-2', 'other', 'mcp__linear__save_issue'), + ]); + render( + + + , + ); + const header = screen.getByRole('button', { name: RE_ACTIVITY_DETAILS }); + expect(header.textContent).toContain('Used Linear 2 times'); + expect(header.querySelector('[data-brand="linear"]')).not.toBeNull(); + cleanup(); + + const failedItem = simpleTool('linear-4', 'other', 'mcp__linear__save_issue'); + failedItem.toolCall.status = 'failed'; + render( + + + , + ); + const failedHeader = screen.getByRole('button', { name: RE_ACTIVITY_DETAILS }); + // The brand glyph persists through failure, but never in the destructive tint — the red + // failure clause carries the state. + const failedGlyph = failedHeader.querySelector('[data-brand="linear"]'); + expect(failedGlyph).not.toBeNull(); + expect(failedGlyph?.classList.contains('text-destructive-foreground')).toBe(false); + expect(failedHeader.textContent).toContain('An action failed'); + expect(failedHeader.textContent).toContain('Used Linear 2 times'); + }); }); diff --git a/packages/presentation/ui/src/chat/__tests__/integration-brand.test.tsx b/packages/presentation/ui/src/chat/__tests__/integration-brand.test.tsx new file mode 100644 index 00000000..f9a99763 --- /dev/null +++ b/packages/presentation/ui/src/chat/__tests__/integration-brand.test.tsx @@ -0,0 +1,28 @@ +// @vitest-environment jsdom + +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { IntegrationIcon, integrationBrand } from '../integration-brand'; + +afterEach(cleanup); + +describe('integration brand resolution', () => { + it('token-matches user-chosen MCP server names against known brands', () => { + expect(integrationBrand('linear')).toBe('linear'); + expect(integrationBrand('claude_ai_Gmail')).toBe('gmail'); + expect(integrationBrand('github-enterprise')).toBe('github'); + expect(integrationBrand('jira')).toBe('jira'); + expect(integrationBrand('f5fcc7d5-d616-4ac2-9cdb-55372529dad2')).toBeUndefined(); + expect(integrationBrand('workspace')).toBeUndefined(); + // Object-prototype keys are not brands (a `constructor` token once resolved to `Object`). + expect(integrationBrand('constructor')).toBeUndefined(); + expect(integrationBrand('my-constructor-server')).toBeUndefined(); + }); + + it('renders the brand glyph with its brand stamped for styling and tests', () => { + const { container } = render(); + const glyph = container.querySelector('[data-brand="linear"]'); + expect(glyph).not.toBeNull(); + expect(glyph?.tagName.toLowerCase()).toBe('svg'); + }); +}); diff --git a/packages/presentation/ui/src/chat/__tests__/tool-call-metadata.test.tsx b/packages/presentation/ui/src/chat/__tests__/tool-call-metadata.test.tsx index 5e13d39d..851a79b0 100644 --- a/packages/presentation/ui/src/chat/__tests__/tool-call-metadata.test.tsx +++ b/packages/presentation/ui/src/chat/__tests__/tool-call-metadata.test.tsx @@ -357,6 +357,31 @@ describe('tool metadata policy', () => { expect(mcpToolName('linear_get_issue')).toBeUndefined(); }); + it('wears a known integration brand glyph in the header icon slot', () => { + const toolCall: ToolCall = { + toolCallId: 'mcp-brand-1', + title: 'mcp__linear__get_issue', + kind: 'other', + status: 'completed', + rawInput: { id: 'CODE-525' }, + content: [], + }; + + const { container } = render(); + expect(container.querySelector('[data-brand="linear"]')).not.toBeNull(); + + cleanup(); + // Failure keeps the brand glyph — the red status label carries the state. + const failed = render(); + expect(failed.container.querySelector('[data-brand="linear"]')).not.toBeNull(); + + cleanup(); + const unbranded = render( + , + ); + expect(unbranded.container.querySelector('[data-brand]')).toBeNull(); + }); + it('headlines an MCP call with its tool name and server context without a redundant badge', () => { const toolCall: ToolCall = { toolCallId: 'mcp-1', diff --git a/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx b/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx index 7c6dd4e1..b579faac 100644 --- a/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx +++ b/packages/presentation/ui/src/chat/__tests__/user-message.test.tsx @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { asyncNoop } from 'foxts/noop'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CommandCatalogProvider } from '../command-brand'; import type { ConversationItem } from '../types'; import { UserMessage } from '../user-message'; @@ -125,4 +126,55 @@ describe('UserMessage', () => { expect(screen.getByRole('button', { name: label }).disabled).toBe(true); }); + + it('chips a catalog-matched command echo with its brand icon, leaving unknowns plain', () => { + const echo = (text: string): Extract => ({ + id: `user-${text}`, + kind: 'message', + role: 'user', + turnId: 'turn-1', + blocks: [{ type: 'text', text }], + isStreaming: false, + }); + const commands = [ + { + name: 'documents', + displayName: 'Documents', + iconDataUri: 'data:image/png;base64,cG5n', + brandColor: '#2563EB', + }, + ]; + + const { container } = render( + + + , + ); + const chip = screen.getByText('/documents'); + expect(chip).toBeDefined(); + expect(screen.getByText('quarterly summary')).toBeDefined(); + expect(container.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,cG5n'); + // The provider's brandColor tints the chip; both mixes keep the brand hue. + expect(chip.style.backgroundColor).toContain('rgb(37, 99, 235)'); + expect(chip.style.color).toContain('color-mix'); + + cleanup(); + const plain = render( + + + , + ); + expect(plain.container.querySelector('img')).toBeNull(); + expect(screen.getByText('/usr/bin/env is a path, not a command')).toBeDefined(); + + // Multi-line arguments keep block rendering — a chip + bare span would collapse newlines. + cleanup(); + const multiline = render( + + + , + ); + expect(multiline.container.querySelector('img')).toBeNull(); + expect(screen.queryByText('/documents')).toBeNull(); + }); }); diff --git a/packages/presentation/ui/src/chat/activity-groups.ts b/packages/presentation/ui/src/chat/activity-groups.ts index 554cfc32..405a95c0 100644 --- a/packages/presentation/ui/src/chat/activity-groups.ts +++ b/packages/presentation/ui/src/chat/activity-groups.ts @@ -1,4 +1,5 @@ import { appendArrayInPlace } from 'foxts/append-array-in-place'; +import { activityItemBrand } from './activity-summary'; import type { ConversationItem } from './types'; type ReasoningTimelineItem = Extract; @@ -23,15 +24,27 @@ export interface ActivityGroupingContext { } export interface ActivityGroupingPolicy { - /** Equal non-null keys form a run until another key or a non-activity item interrupts it. */ + /** Equal non-null keys form a run until another key or a non-activity item interrupts it; + * `ACTIVITY_RUN_GLUE_KEY` joins the surrounding run without setting its key. */ classify(item: ConversationItem, context: ActivityGroupingContext): string | null; minimumGroupSize: number; } const DEFAULT_ACTIVITY_KEY = 'activity'; +/** Classification that extends whatever run surrounds the item instead of keying its own. */ +export const ACTIVITY_RUN_GLUE_KEY = '*'; + export const defaultActivityGroupingPolicy: ActivityGroupingPolicy = { - classify: (item) => (isActivityRunItem(item) ? DEFAULT_ACTIVITY_KEY : null), + // Branded integration calls run in their own dedicated groups, so a failing generic call never + // shares a header (or paints its glyph) with a healthy integration. Thinking is glue — agents + // routinely reason between calls, and a key of its own would split every brand run apart. + classify(item) { + if (!isActivityRunItem(item)) return null; + if (item.kind === 'reasoning' || item.toolCall.kind === 'think') return ACTIVITY_RUN_GLUE_KEY; + const brand = activityItemBrand(item); + return brand === undefined ? DEFAULT_ACTIVITY_KEY : `brand:${brand}`; + }, minimumGroupSize: 2, }; @@ -74,8 +87,10 @@ export function groupTimeline( entries.push({ type: 'item', item }); continue; } - if (runKey !== null && key !== runKey) flushRun(); - runKey = key; + if (key !== ACTIVITY_RUN_GLUE_KEY) { + if (runKey !== null && key !== runKey) flushRun(); + runKey = key; + } run.push(item); } flushRun(); diff --git a/packages/presentation/ui/src/chat/activity-run.tsx b/packages/presentation/ui/src/chat/activity-run.tsx index 678e1b1f..2f6dbbba 100644 --- a/packages/presentation/ui/src/chat/activity-run.tsx +++ b/packages/presentation/ui/src/chat/activity-run.tsx @@ -5,7 +5,11 @@ import { useTranslations } from 'use-intl'; import { cn } from '../lib/cn'; import type { TimelineEntry } from './activity-groups'; import type { ActivitySummaryCategory, ActivitySummaryClause } from './activity-summary'; -import { activityRunCurrentDescriptor, settledActivityRunDescriptor } from './activity-summary'; +import { + activityRunBrand, + activityRunCurrentDescriptor, + settledActivityRunDescriptor, +} from './activity-summary'; import type { QuestionConversationItem } from './conversation-prompts'; import { ChatDisclosureContent } from './disclosure-content'; import { @@ -15,6 +19,8 @@ import { ChatDisclosureChevron, ChatDisclosureIconSlot, } from './disclosure-header'; +import type { IntegrationBrand } from './integration-brand'; +import { INTEGRATION_LABELS, IntegrationIcon } from './integration-brand'; import { QuestionCallItem } from './question-call-item'; import { Shimmer } from './shimmer'; import { ThoughtBlock } from './thought-block'; @@ -43,6 +49,8 @@ export function ActivityRun({ const t = useTranslations('workbench.activityRun'); const current = activityRunCurrentDescriptor(run.items); const settled = settledActivityRunDescriptor(run.items); + const brand = activityRunBrand(run.items); + const brandLabel = brand === undefined ? undefined : INTEGRATION_LABELS[brand]; const firstClause = settled.clauses[0]; const failureClause = firstClause.category === 'failure' ? firstClause : undefined; const hasFailure = failureClause !== undefined; @@ -53,6 +61,12 @@ export function ActivityRun({ ? t('failed', { count: clause.count }) : t('failedMany'); } + // A dedicated brand group names its integration ("Used Linear 2 times"). + if (brandLabel !== undefined && clause.category === 'integration') { + return clause.count <= EXACT_ACTIVITY_COUNT_MAX + ? t('settled.integrationBrand', { brand: brandLabel, count: clause.count }) + : t('settledMany.integrationBrand', { brand: brandLabel }); + } return clause.count <= EXACT_ACTIVITY_COUNT_MAX ? t(`settled.${clause.category}`, { count: clause.count }) : t(`settledMany.${clause.category}`); @@ -62,7 +76,10 @@ export function ActivityRun({ ? [ { key: `running-${current.kind}`, - text: t(`running.${current.kind}`), + text: + brandLabel !== undefined && current.kind === 'other' + ? t('running.integrationBrand', { brand: brandLabel }) + : t(`running.${current.kind}`), failure: false, }, ...(currentSummary @@ -98,6 +115,7 @@ export function ActivityRun({ > ; + } const Icon = category ? ACTIVITY_ICONS[category] : WrenchIcon; - if (failed) return ; - if (running) return ; - return ; + return ; } function primarySettledCategory( diff --git a/packages/presentation/ui/src/chat/activity-summary.ts b/packages/presentation/ui/src/chat/activity-summary.ts index 98cfb17e..9ebe08dd 100644 --- a/packages/presentation/ui/src/chat/activity-summary.ts +++ b/packages/presentation/ui/src/chat/activity-summary.ts @@ -1,4 +1,7 @@ +import { mcpToolName } from '../tool-utils'; import type { ActivityRunItem } from './activity-groups'; +import type { IntegrationBrand } from './integration-brand'; +import { integrationBrand } from './integration-brand'; import { publicReasoningSummary } from './reasoning-summary'; export type ActivitySummaryCategory = @@ -97,6 +100,34 @@ function activityCategory(item: ActivityRunItem): ActivityCategory { return item.kind === 'reasoning' ? 'thinking' : toolDescriptor(item.toolCall.kind).category; } +/** The known integration a run item belongs to, resolved from its MCP server name. */ +export function activityItemBrand(item: ActivityRunItem): IntegrationBrand | undefined { + if (item.kind !== 'tool') return undefined; + const mcp = mcpToolName(item.toolCall.title); + return mcp ? integrationBrand(mcp.server) : undefined; +} + +/** The one brand a run's header may wear: a running branded call always wins (the group is + * visibly doing that integration's work right now); otherwise the run must resolve to a single + * distinct brand — mixed-brand runs keep their category glyph. */ +export function activityRunBrand(items: readonly ActivityRunItem[]): IntegrationBrand | undefined { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item.kind !== 'tool' || !isActiveTool(item)) continue; + const brand = activityItemBrand(item); + if (brand) return brand; + } + + let single: IntegrationBrand | undefined; + for (const item of items) { + const brand = activityItemBrand(item); + if (!brand) continue; + if (single !== undefined && single !== brand) return undefined; + single = brand; + } + return single; +} + type ToolActivityDescriptor = Exclude; type ReasoningActivityDescriptor = Extract; diff --git a/packages/presentation/ui/src/chat/command-brand.tsx b/packages/presentation/ui/src/chat/command-brand.tsx new file mode 100644 index 00000000..0695d209 --- /dev/null +++ b/packages/presentation/ui/src/chat/command-brand.tsx @@ -0,0 +1,64 @@ +import type { AgentCommand } from '@linkcode/schema'; +import { BookTextIcon } from 'lucide-react'; +import { useMemo } from 'react'; +import { cn } from '../lib/cn'; +import { buildCommandLookup, CommandCatalogContext } from './command-catalog'; + +/** A provider-branded command's glyph: its own icon, else a brandColor-tinted initial chip, else + * the shared book glyph. One shape for menu rows, directive chips, and transcript echoes. */ +export function CommandBrandGlyph({ + command, + className, +}: { + command?: Pick; + className?: string; +}): React.ReactNode { + if (command?.iconDataUri) { + return ( + + ); + } + if (command?.brandColor) { + return ( + + {(command.displayName ?? command.name).slice(0, 1)} + + ); + } + return ; +} + +/** Brand tint for a valid command chip, mirroring the Badge info scale: hue from the provider's + * brandColor, text pulled toward the theme foreground so dark brand colors stay legible on dark + * surfaces (and light ones on light). Inline style — the color only exists at runtime. */ +export function commandBrandChipStyle( + command: Pick | undefined, +): React.CSSProperties | undefined { + if (!command?.brandColor) return undefined; + return { + backgroundColor: `color-mix(in srgb, ${command.brandColor} 12%, transparent)`, + color: `color-mix(in srgb, var(--foreground) 25%, ${command.brandColor})`, + }; +} + +export function CommandCatalogProvider({ + commands, + children, +}: { + commands: readonly AgentCommand[] | null; + children: React.ReactNode; +}): React.ReactNode { + // Catalog updates are full-replace, so `commands` identity is the correct invalidation key. + const lookup = useMemo( + () => (commands === null ? null : buildCommandLookup(commands)), + [commands], + ); + return {children}; +} diff --git a/packages/presentation/ui/src/chat/command-catalog.ts b/packages/presentation/ui/src/chat/command-catalog.ts new file mode 100644 index 00000000..f7277654 --- /dev/null +++ b/packages/presentation/ui/src/chat/command-catalog.ts @@ -0,0 +1,25 @@ +import type { AgentCommand } from '@linkcode/schema'; +import { createContext, useContext } from 'react'; + +/** Command lookup keyed by canonical name AND every alias, so transcript chips resolve echoes + * in O(1) during render. Provided by the conversation surface; null when no catalog exists. */ +export const CommandCatalogContext = createContext | null>(null); + +export function buildCommandLookup( + commands: readonly AgentCommand[], +): ReadonlyMap { + const lookup = new Map(); + for (const command of commands) { + for (const name of [command.name, ...(command.aliases ?? [])]) { + if (!lookup.has(name)) lookup.set(name, command); + } + } + return lookup; +} + +/** The catalog entry a command name resolves to (canonical name or alias), if any. */ +export function useCatalogCommand(name: string | undefined): AgentCommand | undefined { + const lookup = useContext(CommandCatalogContext); + if (name === undefined) return undefined; + return lookup?.get(name); +} diff --git a/packages/presentation/ui/src/chat/integration-brand.tsx b/packages/presentation/ui/src/chat/integration-brand.tsx new file mode 100644 index 00000000..60c4f479 --- /dev/null +++ b/packages/presentation/ui/src/chat/integration-brand.tsx @@ -0,0 +1,88 @@ +/// +import SiAsana from '~icons/simple-icons/asana'; +import SiAtlassian from '~icons/simple-icons/atlassian'; +import SiCloudflare from '~icons/simple-icons/cloudflare'; +import SiFigma from '~icons/simple-icons/figma'; +import SiGithub from '~icons/simple-icons/github'; +import SiGmail from '~icons/simple-icons/gmail'; +import SiGoogledrive from '~icons/simple-icons/googledrive'; +import SiIntercom from '~icons/simple-icons/intercom'; +import SiLinear from '~icons/simple-icons/linear'; +import SiNotion from '~icons/simple-icons/notion'; +import SiPostgresql from '~icons/simple-icons/postgresql'; +import SiSentry from '~icons/simple-icons/sentry'; +import SiSlack from '~icons/simple-icons/slack'; +import SiStripe from '~icons/simple-icons/stripe'; +import SiSupabase from '~icons/simple-icons/supabase'; +import SiVercel from '~icons/simple-icons/vercel'; +import { cn } from '../lib/cn'; + +/** Brand glyphs for well-known integrations, keyed by the token found in an MCP server name. + * Static imports only — never construct a virtual icon path dynamically. */ +const INTEGRATION_GLYPHS = { + asana: SiAsana, + atlassian: SiAtlassian, + cloudflare: SiCloudflare, + figma: SiFigma, + github: SiGithub, + gmail: SiGmail, + googledrive: SiGoogledrive, + intercom: SiIntercom, + jira: SiAtlassian, + linear: SiLinear, + notion: SiNotion, + postgres: SiPostgresql, + postgresql: SiPostgresql, + sentry: SiSentry, + slack: SiSlack, + stripe: SiStripe, + supabase: SiSupabase, + vercel: SiVercel, +} as const; + +export type IntegrationBrand = keyof typeof INTEGRATION_GLYPHS; + +/** Proper brand casing for labels ("Used Linear 2 times"). */ +export const INTEGRATION_LABELS: Record = { + asana: 'Asana', + atlassian: 'Atlassian', + cloudflare: 'Cloudflare', + figma: 'Figma', + github: 'GitHub', + gmail: 'Gmail', + googledrive: 'Google Drive', + intercom: 'Intercom', + jira: 'Jira', + linear: 'Linear', + notion: 'Notion', + postgres: 'PostgreSQL', + postgresql: 'PostgreSQL', + sentry: 'Sentry', + slack: 'Slack', + stripe: 'Stripe', + supabase: 'Supabase', + vercel: 'Vercel', +}; + +const RE_SERVER_TOKEN_BOUNDARY = /[^a-z0-9]+/; + +/** MCP server names are user-chosen config keys (`linear`, `claude_ai_Gmail`, opaque ids) — + * token-match them against the known brands; no match means no branding. */ +export function integrationBrand(server: string): IntegrationBrand | undefined { + for (const token of server.toLowerCase().split(RE_SERVER_TOKEN_BOUNDARY)) { + // Own-key check: `in` would also match prototype keys ("constructor" is a valid token). + if (Object.hasOwn(INTEGRATION_GLYPHS, token)) return token as IntegrationBrand; + } + return undefined; +} + +export function IntegrationIcon({ + brand, + className, +}: { + brand: IntegrationBrand; + className?: string; +}): React.ReactNode { + const Glyph = INTEGRATION_GLYPHS[brand]; + return ; +} diff --git a/packages/presentation/ui/src/chat/tool-call-item.tsx b/packages/presentation/ui/src/chat/tool-call-item.tsx index 0b7038eb..f3fc5a35 100644 --- a/packages/presentation/ui/src/chat/tool-call-item.tsx +++ b/packages/presentation/ui/src/chat/tool-call-item.tsx @@ -13,6 +13,7 @@ import { toolCallMetadata, toolCallSearchCounts, } from '../tool-utils'; +import { IntegrationIcon, integrationBrand } from './integration-brand'; import { Tool, ToolContent, ToolHeader } from './tool'; import { toolCallDisplayText, toolSearchPresentation } from './tool-result-content'; import { ToolResultPreview } from './tool-result-preview'; @@ -103,6 +104,9 @@ export function ToolCallItem({ const searchCounts = toolSearch ? undefined : toolCallSearchCounts(toolCall); let title = mcp?.tool ?? toolCall.title; let summary = toolCallContextSummary(toolCall); + // Glyph priority: caller-supplied plugin icon, ToolSearch toolbox, integration brand, kind + // icon. State glyphs override inside ToolIcon — except failed, which keeps a supplied glyph. + const brand = mcp ? integrationBrand(mcp.server) : undefined; let headerIcon = icon; if (toolSearch && !headerIcon) { headerIcon = ( @@ -111,6 +115,14 @@ export function ToolCallItem({ /> ); } + if (brand && !headerIcon) { + headerIcon = ( + + ); + } if (toolSearch) { title = toolSearch.mode === 'select' diff --git a/packages/presentation/ui/src/chat/tool.tsx b/packages/presentation/ui/src/chat/tool.tsx index 077f6451..cd0df6cb 100644 --- a/packages/presentation/ui/src/chat/tool.tsx +++ b/packages/presentation/ui/src/chat/tool.tsx @@ -154,7 +154,9 @@ export function ToolIcon({ } if (declined) return ; if (status === 'failed') { - return ; + // Any caller-supplied glyph (brand, plugin, ToolSearch) keeps a failed call recognizable; + // the red status label already carries the state. + return icon ?? ; } if (awaitingAnswer) { return ; diff --git a/packages/presentation/ui/src/chat/user-message.tsx b/packages/presentation/ui/src/chat/user-message.tsx index ffe81bd6..84e107b7 100644 --- a/packages/presentation/ui/src/chat/user-message.tsx +++ b/packages/presentation/ui/src/chat/user-message.tsx @@ -8,9 +8,12 @@ import { CheckIcon, ChevronDownIcon, CopyIcon, PencilIcon } from 'lucide-react'; import { useState } from 'react'; import { useFormatter, useTranslations } from 'use-intl'; import { cn } from '../lib/cn'; +import { CommandBrandGlyph, commandBrandChipStyle } from './command-brand'; +import { useCatalogCommand } from './command-catalog'; import { ContentBlockView } from './content-block-view'; import { positionalBlockEntries } from './content-derived-keys'; import { contentBlocksText } from './conversation-text'; +import { Chip } from './link-chip'; import { Message, MessageAction, MessageActions, MessageContent } from './message'; import type { ConversationItem, PromptEditState } from './types'; import { useCopyButton } from './use-copy-button'; @@ -19,6 +22,19 @@ import { useCopyButton } from './use-copy-button'; const COLLAPSE_LINE_COUNT = 20; const COPY_FEEDBACK_MS = 2000; +const RE_WHITESPACE = /\s/; + +/** A command echo is exactly what the composer sent: `/name` and optional single-line argument + * text. Multi-line arguments keep block rendering — a bare span would collapse their newlines. */ +function commandEcho(text: string): { name: string; args: string } | undefined { + if (text[0] !== '/' || text.includes('\n')) return undefined; + const body = text.slice(1); + const nameEnd = body.search(RE_WHITESPACE); + if (nameEnd === 0 || body.length === 0) return undefined; + if (nameEnd === -1) return { name: body, args: '' }; + return { name: body.slice(0, nameEnd), args: body.slice(nameEnd).trim() }; +} + type MessageItem = Extract; /** A user bubble: collapses long messages, with copy/edit and the send time revealed on hover. */ @@ -91,6 +107,11 @@ export function UserMessage({ } } + // A catalog-matched `/command args` echo chips its invocation like the composer draft did. + // Unknown leading slashes (paths, prose) stay plain text. + const echo = item.blocks.length === 1 ? commandEcho(text) : undefined; + const echoedCommand = useCatalogCommand(echo?.name); + return (
- {positionalBlockEntries(item.blocks).map(({ block, key }) => ( - - ))} + {echo && echoedCommand ? ( +

+ + /{echo.name} + + {echo.args ? {echo.args} : null} +

+ ) : ( + positionalBlockEntries(item.blocks).map(({ block, key }) => ( + + )) + )}
{collapsible ? (