diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index a67fb9f2..2306cb99 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -132,3 +132,26 @@ existing diagnostic-counts projection; raw error text, stacks and session IDs are not added to the uploaded ZIP. Offline tests cover persisted tool histories, archives, concurrent parent output, side-session cleanup and local diagnostics; this does not establish native-terminal or live-model acceptance. + +## Select a plugin for a message + +Type `@` in the Composer to search files and installed, enabled plugins. Plugin +candidates show their source so packages with the same display name can be +selected independently. Choose a plugin with Tab or Enter, then describe the task. +The Composer shows `@Name` and retains the plugin identity through editing, undo, +prompt history, queued-message recovery, saved drafts, and `/edit` after a +message is sent. Ctrl+C clearing/restoration and external-editor edits retain +unchanged plugin bindings. If external edits make duplicate labels ambiguous, +reselect those plugins in the Composer. Displayed messages remain readable; Runtime retains +the original input separately when needed to recover the plugin identity for editing. + +Selection applies to that message. Runtime checks the plugin's effective Skills, +MCP tools, and App tools again for the turn and asks the Agent to prefer relevant +capabilities. Selecting a plugin does not install or enable it. An unavailable +selection is reported to the Agent rather than redirected to a same-named package. + +Exec and ACP text prompts can use the durable linked form, for example +`[@Notes](plugin://notes%40local) summarize these files`. The ID is the package +name plus its `local` or `official` source; display labels do not determine the +selection. Legacy whitespace-delimited `@package-name` text remains supported +when it identifies exactly one effective plugin. diff --git a/packages/agent-modules/system-reminder/package.json b/packages/agent-modules/system-reminder/package.json index 1ee0840c..0ddefcd9 100644 --- a/packages/agent-modules/system-reminder/package.json +++ b/packages/agent-modules/system-reminder/package.json @@ -14,7 +14,8 @@ "@mavis/config": "workspace:^", "@types/node": "^20", "typescript": "^5", - "vitest": "^2" + "vitest": "^2", + "@mavis/shared": "workspace:^" }, "private": true, "license": "MIT" diff --git a/packages/agent-modules/system-reminder/src/plugin-reference.ts b/packages/agent-modules/system-reminder/src/plugin-reference.ts index 5878a928..2ad38865 100644 --- a/packages/agent-modules/system-reminder/src/plugin-reference.ts +++ b/packages/agent-modules/system-reminder/src/plugin-reference.ts @@ -1,12 +1,15 @@ +import { parsePluginMentions } from '@mavis/shared/plugin-mention'; + export interface EffectivePluginToolGroup { readonly source: string; readonly tools: readonly string[]; - /** Deferred App tools must be discovered before they can be invoked. */ + /** Deferred tools must be discovered before they can be invoked. */ readonly access?: 'direct' | 'tool_search'; } export interface EffectivePluginCapabilityInventory { readonly name: string; + readonly pluginId?: string; readonly appTools: readonly EffectivePluginToolGroup[]; readonly mcpTools: readonly EffectivePluginToolGroup[]; readonly skills: readonly string[]; @@ -33,7 +36,7 @@ export function detectPluginReferencesForMessages { + if (plugins.length === 0 && unavailablePluginIds.length === 0) return undefined; + const blocks = plugins.slice(0, 8).map((plugin) => { const name = inlineCode(plugin.name); - return [ + const detail = [ ``, `Referenced as \`@${name}\`.`, '', @@ -66,20 +70,46 @@ export function buildPluginReferenceReminder( '', '', ].join('\n'); + return detail.length <= 4096 + ? detail + : `\nCapability details omitted to fit the context limit. Use this Plugin's tool provenance and Skill namespace in the available catalogs.\n`; }); + const bounded: string[] = []; + let remaining = 12_288; + let omitted = plugins.length > 8 || unavailablePluginIds.length > 8; + for (const block of [ + ...blocks, + ...unavailablePluginIds + .slice(0, 8) + .map( + (id) => + `Selected Plugin \`${inlineCode(id)}\` is unavailable for this turn. Tell the user it could not be used; do not substitute a same-named Plugin or enable/install it automatically.`, + ), + ]) { + if (block.length + 2 > remaining) { + omitted = true; + continue; + } + bounded.push(block); + remaining -= block.length + 2; + } + return [ '', 'The user explicitly selected the following Plugin capabilities for this request.', 'Prefer them when relevant; other tools remain available if needed.', '', - blocks.join('\n\n'), + bounded.join('\n\n'), + ...(omitted ? ['Additional selected capabilities omitted to fit the context limit.'] : []), '', - 'Only the capabilities listed above are effective for this turn.', + 'The listed capabilities are drawn from the effective inventory for this turn; lists may be truncated.', 'Do not invent or claim unavailable Plugin capabilities.', - ...(plugins.some((plugin) => plugin.appTools.some((group) => group.access === 'tool_search')) + ...(plugins.some((plugin) => + [...plugin.appTools, ...plugin.mcpTools].some((group) => group.access === 'tool_search'), + ) ? [ - 'For App tools marked `via tool_search + mcp_invoke`, discover the exact tool with `tool_search` before calling it through `mcp_invoke`.', + 'For tools marked `via tool_search + mcp_invoke`, discover the exact tool with `tool_search` before calling it through `mcp_invoke`.', ] : []), 'Before following a listed Skill, call the `skill` tool with its exact name.', @@ -91,6 +121,7 @@ export function buildPluginReferenceReminder( function formatAppToolGroups(groups: readonly EffectivePluginToolGroup[]): string { if (groups.length === 0) return 'none'; return [...groups] + .slice(0, 16) .sort((left, right) => { const sourceOrder = normalizedName(left.source).localeCompare(normalizedName(right.source)); if (sourceOrder !== 0) return sourceOrder; @@ -108,11 +139,24 @@ function detectReferencesInText( text: string, plugins: readonly T[], ): T[] { - const normalizedText = text.normalize('NFKC'); + const linked = parsePluginMentions(text); + let plainText = text; + for (const mention of [...linked].reverse()) + plainText = `${plainText.slice(0, mention.start)}${' '.repeat(mention.end - mention.start)}${plainText.slice(mention.end)}`; + const normalizedText = plainText.normalize('NFKC'); const matches: Array<{ index: number; plugin: T }> = []; for (const plugin of plugins) { + const explicit = linked.find((mention) => mention.pluginId === plugin.pluginId); + if (explicit) { + matches.push({ index: explicit.start, plugin }); + continue; + } const name = normalizedName(plugin.name); - if (!name) continue; + if ( + !name || + plugins.filter((candidate) => normalizedName(candidate.name) === name).length !== 1 + ) + continue; const pattern = new RegExp(`(^|\\s)@${escapeRegExp(name)}(?=\\s|$)`, 'giu'); const match = pattern.exec(normalizedText); if (!match) continue; @@ -128,16 +172,19 @@ function formatToolGroups( ): string { if (groups.length === 0) return 'none'; return [...groups] + .slice(0, 16) .sort((left, right) => normalizedName(left.source).localeCompare(normalizedName(right.source))) .map((group) => { const tools = formatToolNames(group.tools); - return `- ${label} \`${inlineCode(group.source)}\`: ${tools || 'none'}`; + const access = group.access === 'tool_search' ? ' via `tool_search` + `mcp_invoke`' : ''; + return `- ${label} \`${inlineCode(group.source)}\`${access}: ${tools || 'none'}`; }) .join('\n'); } function formatToolNames(tools: readonly string[]): string { return [...new Set(tools)] + .slice(0, 32) .sort((left, right) => normalizedName(left).localeCompare(normalizedName(right))) .map((tool) => `\`${inlineCode(tool)}\``) .join(', '); @@ -146,6 +193,7 @@ function formatToolNames(tools: readonly string[]): string { function formatSkills(skills: readonly string[]): string { if (skills.length === 0) return 'none'; return [...new Set(skills)] + .slice(0, 32) .sort((left, right) => normalizedName(left).localeCompare(normalizedName(right))) .map((skill) => `- \`${inlineCode(skill)}\``) .join('\n'); diff --git a/packages/local-runtime-v2/src/application/conversation/conversation-application.ts b/packages/local-runtime-v2/src/application/conversation/conversation-application.ts index c7be0ea1..1f7c92f8 100644 --- a/packages/local-runtime-v2/src/application/conversation/conversation-application.ts +++ b/packages/local-runtime-v2/src/application/conversation/conversation-application.ts @@ -70,6 +70,8 @@ import { } from "./session-stream-delivery.js"; export interface ConversationSendMessageRequest extends SendMessageReq { + /** Readable projection supplied by the process-local composer; content retains transport identity. */ + readonly displayContent?: string; /** Process-local caller's Unix-ms deadline; its cancellation owner still enforces it. */ readonly executionDeadlineAtMs?: number; /** Process-local opt-in; emits metadata only, never provider payloads. */ @@ -575,6 +577,9 @@ async function toDirectSendInput( input: toAgentHostUserInput(req, text, attachments), ...executionOptions, provenance: messageProvenance(req), + ...(req.displayContent !== undefined + ? { displayContent: req.displayContent } + : {}), ...(materialized.displayAttachments.length > 0 ? { displayAttachments: materialized.displayAttachments } : {}), diff --git a/packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts b/packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts index 28f3e872..3c2a6e91 100644 --- a/packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts +++ b/packages/local-runtime-v2/src/application/conversation/direct-send-delivery.ts @@ -26,6 +26,7 @@ export interface DirectSendInput { readonly outputContract?: SubmitTurnSubmission['outputContract']; readonly executionDeadlineAtMs?: SubmitTurnSubmission['executionDeadlineAtMs']; readonly provenance: SubmitTurnSubmission['provenance']; + readonly displayContent?: string; readonly displayAttachments?: readonly UserMessageAttachment[]; readonly requestedTurnId?: string; readonly hideUserMessage?: boolean; @@ -388,8 +389,12 @@ async function completeAfterSettlement( function directSendDisplayOptions( input: DirectSendInput, -): Pick, 'displayAttachments' | 'hideUserMessage'> { +): Pick< + NonNullable, + 'displayContent' | 'displayAttachments' | 'hideUserMessage' +> { return { + ...(input.displayContent !== undefined ? { displayContent: input.displayContent } : {}), ...(input.displayAttachments ? { displayAttachments: input.displayAttachments.map((attachment) => ({ ...attachment })) } : {}), diff --git a/packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts b/packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts index 22fb114c..c4aa266b 100644 --- a/packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts +++ b/packages/local-runtime-v2/src/application/conversation/user-message-turn-delivery.ts @@ -1,3 +1,4 @@ +import { parsePluginMentions } from '@mavis/shared/plugin-mention'; import { randomUUID } from 'node:crypto'; import { @@ -126,6 +127,7 @@ export class UserMessageTurnDeliveryService implements UserMessageTurnDelivery { ...(input.message.userMessageId ? { userMessageId: input.message.userMessageId } : {}), unstartedFromTurnIds: input.message.unstartedFromTurnIds, content: projectedMessageContent(delivery, input.message.message.text), + inputContent: input.message.message.text, ...(attachments ? { attachments } : {}), ...(input.message.sourceMessageId ? { sourceMessageId: input.message.sourceMessageId } : {}), provenance: input.message.provenance, @@ -295,6 +297,7 @@ async function commitTurnMessages( messageKey: member.messageKey, userMessageId: member.userMessageId, content: projectedMessageContent(member.message, member.message.content), + inputContent: member.message.content, timestamp: member.createdAt, ...(member.unconsumedFromTurnIds ? { unconsumedFromTurnIds: member.unconsumedFromTurnIds } @@ -323,6 +326,7 @@ async function commitTurnMessage( messageKey: input.messageKey ?? `turn:${turnId}`, ...(input.userMessageId ? { userMessageId: input.userMessageId } : {}), content: projectedMessageContent(input, input.input.text), + inputContent: input.input.text, ...(queryKey ? { queryKey } : {}), ...(attachments ? { attachments } : {}), ...(input.sourceMessageId ? { sourceMessageId: input.sourceMessageId } : {}), @@ -340,6 +344,7 @@ function commitMessage( readonly messageKey: string; readonly userMessageId?: UserMessageId; readonly content: string; + readonly inputContent: string; readonly timestamp?: number; readonly unconsumedFromTurnIds?: readonly string[]; readonly unstartedFromTurnIds?: readonly string[]; @@ -360,6 +365,9 @@ function commitMessage( messageKey: input.messageKey, ...(input.userMessageId ? { userMessageId: input.userMessageId } : {}), content: input.content, + ...(input.inputContent !== input.content && parsePluginMentions(input.inputContent).length > 0 + ? { editContent: input.inputContent } + : {}), ...(input.timestamp !== undefined ? { timestamp: input.timestamp } : {}), ...(input.unconsumedFromTurnIds ? { unconsumedFromTurnIds: input.unconsumedFromTurnIds } diff --git a/packages/local-runtime-v2/src/application/session/content-application.ts b/packages/local-runtime-v2/src/application/session/content-application.ts index 81e295e4..27b69097 100644 --- a/packages/local-runtime-v2/src/application/session/content-application.ts +++ b/packages/local-runtime-v2/src/application/session/content-application.ts @@ -481,6 +481,7 @@ export function toSessionMessageView( ); return { msgId: maybeString(firstDefined([message.msgId, message.msg_id])) ?? "", + ...(typeof message.editContent === "string" ? { editContent: message.editContent } : {}), parentMsgId: maybeString( firstDefined([message.parentMsgId, message.parent_msg_id]), ), diff --git a/packages/local-runtime-v2/src/service/session-system/messages/repo/contract.ts b/packages/local-runtime-v2/src/service/session-system/messages/repo/contract.ts index 197c3f35..f2b38fc5 100644 --- a/packages/local-runtime-v2/src/service/session-system/messages/repo/contract.ts +++ b/packages/local-runtime-v2/src/service/session-system/messages/repo/contract.ts @@ -2,6 +2,8 @@ import type { AppDb } from '../../../../infra/db/client.js'; export interface DisplayMessageRecord extends Record { readonly msg_id?: string; + /** Canonical user input preserved when display text hides plugin identities. */ + readonly editContent?: string; /** Stable canonical identity of this completed assistant message, when known. */ readonly canonical_message_id?: string; readonly role?: string; diff --git a/packages/local-runtime-v2/src/service/session-system/messages/user-message-commit-service.ts b/packages/local-runtime-v2/src/service/session-system/messages/user-message-commit-service.ts index d6c54d10..589f0796 100644 --- a/packages/local-runtime-v2/src/service/session-system/messages/user-message-commit-service.ts +++ b/packages/local-runtime-v2/src/service/session-system/messages/user-message-commit-service.ts @@ -47,6 +47,8 @@ export interface CommitUserMessageInput { /** Pre-generated identity shared by Queue, Display and canonical history. */ readonly userMessageId?: UserMessageId; readonly content?: string; + /** Canonical input retained when readable display text hides explicit plugin identities. */ + readonly editContent?: string; /** Display classification independent from Turn/query identity. */ readonly kind?: string; /** Query sidecar identity; absent only for legacy or non-query user Messages. */ @@ -118,6 +120,7 @@ export class UserMessageCommitService { msg_id: messageId, role: 'user', msg_content: input.content ?? '', + ...(input.editContent !== undefined ? { editContent: input.editContent } : {}), msg_type: 1, timestamp: input.timestamp ?? this.nowMs(), kind: input.kind, diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts new file mode 100644 index 00000000..28432121 --- /dev/null +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts @@ -0,0 +1,169 @@ +import { buildLocalTurnToolCatalog } from './local-turn-tool-catalog.js'; +import { Type } from '@sinclair/typebox'; +import { describe, expect, it, vi } from 'vitest'; +import type { RuntimeTool } from '@mavis/agent-core/tools'; +import { + detectPluginReferencesForMessages, + buildPluginReferenceReminder, +} from '@mavis/system-reminder'; +import { + mergeAgentHostTurnCapabilities, + type MergeAgentHostTurnCapabilitiesInput, +} from './local-turn-plugin-capabilities.js'; + +function tool(name: string): RuntimeTool { + return { + def: { name, description: 'Synthetic test tool', schema: Type.Object({}) }, + impl: { + execute: vi.fn(async () => ({ + tool_name: name, + text: 'ok', + content: [{ type: 'text' as const, text: 'ok' }], + })), + }, + }; +} +function input( + userText = '[@My Notes](plugin://notes%40local) summarize', +): MergeAgentHostTurnCapabilitiesInput { + const mcp = tool('mcp__notes__read'); + const app = tool('app_notes_search'); + return { + tools: [], + plan: { deferred: false, inlineTools: [] }, + model: { provider: 'synthetic', id: 'offline', contextWindow: 128000 }, + options: { + enabled: true, + modelWhitelist: [], + thresholdPct: 10, + minDeferCount: 1, + topKDefault: 5, + topKMax: 10, + systemHint: true, + maxSchemaTextLen: 1000, + }, + effectivePluginSkills: [{ pluginName: 'notes', name: 'notes:organize' }], + capabilities: { + revision: 'r1', + plugins: [{ name: 'notes', source: 'local', appProviders: ['notes-app'] }], + skills: [], + runtimeTools: [mcp, app], + runtimeToolBindings: [ + { kind: 'mcp', source: 'notes-server', pluginName: 'notes', tool: mcp }, + { kind: 'app', source: 'notes-app', toolMode: 'tool_search', tool: app }, + ], + }, + userText, + }; +} + +describe('Plugin identity selection at the final Runtime capability boundary', () => { + it('selects exact IDs independently of aliases and never substitutes a same-named source', () => { + const local = { + name: 'notes', + pluginId: 'notes@local', + skills: ['notes:organize'], + appTools: [], + mcpTools: [], + }; + const official = { ...local, pluginId: 'notes@official' }; + expect( + detectPluginReferencesForMessages([], '[@Alias](plugin://notes%40local)', [official, local]), + ).toEqual([local]); + expect( + detectPluginReferencesForMessages([], '[@notes](plugin://notes%40missing)', [local]), + ).toEqual([]); + expect(detectPluginReferencesForMessages([], '@notes summarize', [official, local])).toEqual( + [], + ); + expect(detectPluginReferencesForMessages([], '@notes summarize', [local])).toEqual([local]); + }); + + it('advertises only effective skills, direct MCP and searchable App tools, preserving callable implementations', async () => { + const request = input(); + const result = mergeAgentHostTurnCapabilities(request); + expect(result.reminder).toContain('notes:organize'); + expect(result.reminder).toContain('mcp__notes__read'); + expect(result.reminder).toContain('app_notes_search'); + expect(result.reminder).toContain('tool_search'); + expect(result.plan.deferred).toBe(true); + const mcp = result.tools.find((tool) => tool.def.name === 'mcp__notes__read')!; + await expect(mcp.impl.execute({ sessionId: 's', turnId: 't' }, {})).resolves.toMatchObject({ + text: 'ok', + }); + expect(mcp.toolCallProvenanceResolver?.({ phase: 'start', toolName: mcp.def.name })).toEqual([ + expect.objectContaining({ plugin_name: 'notes' }), + ]); + }); + + it('discovers and invokes deferred MCP and App tools through the final catalog', async () => { + const request = input(); + const mcp = request.capabilities!.runtimeTools[0]!; + const result = buildLocalTurnToolCatalog({ + sessionId: 's', + llmModel: request.model, + sources: { + nativeTools: [], + mcpEntries: [{ tool: mcp, source: 'configured', serverName: 'notes-server' }], + threadGoalTools: [], + cuRuntimeAvailable: false, + }, + config: { ...request.options, modelWhitelist: ['*'], thresholdPct: 0 }, + desktopCapabilities: request.capabilities, + effectivePluginSkills: request.effectivePluginSkills, + userText: request.userText, + }); + expect(result.tools.map((tool) => tool.def.name)).not.toContain(mcp.def.name); + expect(result.userPromptPrefix).toContain( + 'server `notes-server` via `tool_search` + `mcp_invoke`', + ); + const search = result.tools.find((tool) => tool.def.name === 'tool_search')!; + const invoke = result.tools.find((tool) => tool.def.name === 'mcp_invoke')!; + for (const name of ['mcp__notes__read', 'app_notes_search']) { + const found = await search.impl.execute({ sessionId: 's', turnId: 't' }, { query: name }); + expect(JSON.stringify(found)).toContain(name); + await expect( + invoke.impl.execute({ sessionId: 's', turnId: 't' }, { tool_name: name, arguments: {} }), + ).resolves.toMatchObject({ text: 'ok' }); + } + }); + + it('reports a plugin disabled before this turn and does not activate it or use another source', () => { + const request = input(); + const result = mergeAgentHostTurnCapabilities({ + ...request, + effectivePluginSkills: [], + capabilities: { + revision: 'r2', + plugins: [], + runtimeTools: [], + runtimeToolBindings: [], + skills: [], + }, + }); + expect(result.reminder).toContain('notes@local'); + expect(result.reminder).toContain('unavailable for this turn'); + expect(result.tools).toEqual([]); + expect( + mergeAgentHostTurnCapabilities({ ...request, capabilities: undefined }).reminder, + ).toContain('unavailable'); + expect( + mergeAgentHostTurnCapabilities(input('[@notes](plugin://notes%40official)')).reminder, + ).toContain('notes@official` is unavailable'); + }); + + it('bounds capability instructions even for large inventories', () => { + const result = buildPluginReferenceReminder( + Array.from({ length: 40 }, (_, index) => ({ + name: `plugin-${index}`, + pluginId: `plugin-${index}@local`, + skills: Array.from({ length: 200 }, (_, i) => `plugin-${index}:skill-${i}`), + appTools: [], + mcpTools: [], + })), + ); + expect(result!.length).toBeLessThan(16384); + expect(result).toContain('omitted'); + expect(result).toContain(''); + }); +}); diff --git a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts index 6d7656a1..0f956f1c 100644 --- a/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts +++ b/packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts @@ -1,3 +1,4 @@ +import { buildPluginId, parsePluginMentions } from '@mavis/shared/plugin-mention'; import type { RuntimeTool } from '@mavis/agent-core/tools'; import { attachPluginCapabilityAttribution, @@ -66,7 +67,10 @@ export interface MergeAgentHostTurnCapabilitiesInput { readonly capabilities?: AgentHostTurnCapabilityView; readonly model: McpModelIdentity; readonly options: McpDisclosureOptions; - readonly effectivePluginSkills: readonly { readonly pluginName: string; readonly name: string }[]; + readonly effectivePluginSkills: readonly { + readonly pluginName: string; + readonly name: string; + }[]; readonly hostCapabilityRegistry?: HostCapabilityResolver; readonly surface?: HostCapabilitySurface; readonly userText?: string; @@ -84,7 +88,13 @@ export function mergeAgentHostTurnCapabilities( ): MergedAgentHostTurnCapabilities { const merged = mergeTools(input); const capabilities = input.capabilities; - if (!capabilities) return merged; + if (!capabilities) { + const reminder = buildPluginReferenceReminder( + [], + parsePluginMentions(input.userText ?? '').map((mention) => mention.pluginId), + ); + return { ...merged, ...(reminder ? { reminder } : {}) }; + } const attributed = { ...merged, tools: attachPluginCapabilityAttribution( @@ -98,29 +108,38 @@ export function mergeAgentHostTurnCapabilities( ), }; if (input.userText === undefined) return attributed; - const reminder = buildPluginReferenceReminder( - detectPluginReferencesForMessages( - [{ content: input.userText }], - input.userText, - buildCapabilityInventory({ - capabilities, - effectivePluginSkills: input.effectivePluginSkills, - // Attribution decorates relevant tools with cloned wrapper objects. - // Inventory filtering must retain the pre-decoration identities used - // by runtimeToolBindings and the deferred registry. - finalTools: merged.tools, - deferredToolNames: new Set( - attributed.plan.deferred ? attributed.plan.deferredRegistry.keys() : [], - ), - }), + const inventory = buildCapabilityInventory({ + capabilities, + effectivePluginSkills: input.effectivePluginSkills, + // Inventory filtering uses the pre-attribution tool identities. + finalTools: merged.tools, + deferredToolNames: new Set( + attributed.plan.deferred ? attributed.plan.deferredRegistry.keys() : [], ), + }); + const unavailable = [ + ...new Set(parsePluginMentions(input.userText).map((mention) => mention.pluginId)), + ].filter( + (id) => + !inventory.some( + (plugin) => + plugin.pluginId === id && + (plugin.skills.length > 0 || plugin.appTools.length > 0 || plugin.mcpTools.length > 0), + ), + ); + const reminder = buildPluginReferenceReminder( + detectPluginReferencesForMessages([{ content: input.userText }], input.userText, inventory), + unavailable, ); return { ...attributed, ...(reminder ? { reminder } : {}) }; } function buildAttributionIndex(input: { readonly capabilities: AgentHostTurnCapabilityView; - readonly effectivePluginSkills: readonly { readonly pluginName: string; readonly name: string }[]; + readonly effectivePluginSkills: readonly { + readonly pluginName: string; + readonly name: string; + }[]; readonly finalTools: readonly RuntimeTool[]; readonly plan: McpDisclosurePlan; }): PluginCapabilityAttributionIndex { @@ -273,7 +292,10 @@ function mergeSearchableTools( ); for (const tool of selected.searchable) deferredRegistry.set(tool.def.name, tool); const index = buildOrReuseIndex( - [...deferredRegistry.values()].map((tool) => ({ tool, source: 'configured' as const })), + [...deferredRegistry.values()].map((tool) => ({ + tool, + source: 'configured' as const, + })), { maxSchemaTextLen: input.options.maxSchemaTextLen }, ); const estimate = input.options.estimateTokens ?? estimateToolTokens; @@ -312,16 +334,20 @@ function mergeSearchableTools( function buildCapabilityInventory(input: { readonly capabilities: AgentHostTurnCapabilityView; - readonly effectivePluginSkills: readonly { readonly pluginName: string; readonly name: string }[]; + readonly effectivePluginSkills: readonly { + readonly pluginName: string; + readonly name: string; + }[]; readonly finalTools: readonly RuntimeTool[]; readonly deferredToolNames: ReadonlySet; }): EffectivePluginCapabilityInventory[] { const finalTools = new Set(input.finalTools); - const searchableAppToolsAvailable = + const searchableToolsAvailable = input.finalTools.some((tool) => tool.def.name === 'tool_search') && input.finalTools.some((tool) => tool.def.name === 'mcp_invoke'); return input.capabilities.plugins.map((plugin) => ({ name: plugin.name, + pluginId: buildPluginId(plugin.name, plugin.source), appTools: plugin.appProviders.flatMap((provider) => { const bindings = input.capabilities.runtimeToolBindings.filter( (binding) => binding.kind === 'app' && binding.source === provider, @@ -333,7 +359,7 @@ function buildCapabilityInventory(input: { ? [binding.tool.def.name] : [], ); - const searchable = searchableAppToolsAvailable + const searchable = searchableToolsAvailable ? bindings.flatMap((binding) => binding.toolMode === 'tool_search' && input.deferredToolNames.has(binding.tool.def.name) ? [binding.tool.def.name] @@ -343,18 +369,37 @@ function buildCapabilityInventory(input: { return [ ...(direct.length > 0 ? [{ source: provider, tools: direct }] : []), ...(searchable.length > 0 - ? [{ source: provider, tools: searchable, access: 'tool_search' as const }] + ? [ + { + source: provider, + tools: searchable, + access: 'tool_search' as const, + }, + ] : []), ]; }), - mcpTools: groupMcpTools( - input.capabilities.runtimeToolBindings.filter( - (binding) => - binding.kind === 'mcp' && - binding.pluginName === plugin.name && - finalTools.has(binding.tool), + mcpTools: [ + ...groupMcpTools( + input.capabilities.runtimeToolBindings.filter( + (binding) => + binding.kind === 'mcp' && + binding.pluginName === plugin.name && + finalTools.has(binding.tool), + ), ), - ), + ...(searchableToolsAvailable + ? groupMcpTools( + input.capabilities.runtimeToolBindings.filter( + (binding) => + binding.kind === 'mcp' && + binding.pluginName === plugin.name && + !finalTools.has(binding.tool) && + input.deferredToolNames.has(binding.tool.def.name), + ), + ).map((group) => ({ ...group, access: 'tool_search' as const })) + : []), + ], skills: input.effectivePluginSkills.flatMap((skill) => skill.pluginName === plugin.name ? [skill.name] : [], ), diff --git a/packages/protocol/src/local.ts b/packages/protocol/src/local.ts index c313e961..10c8268f 100644 --- a/packages/protocol/src/local.ts +++ b/packages/protocol/src/local.ts @@ -339,6 +339,8 @@ export interface MemoryReferenceView { export interface SessionMessageView { msgId: string; + /** Canonical user text for editing when display text omits plugin identities. */ + editContent?: string; parentMsgId?: string; timestamp?: number; msgContent?: string; diff --git a/packages/shared/package.json b/packages/shared/package.json index 57e7636a..456b4e3a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -191,6 +191,10 @@ "./safety-check-v2": { "types": "./src/safety-check-v2.ts", "import": "./dist/safety-check-v2.js" + }, + "./plugin-mention": { + "types": "./src/plugin-mention.ts", + "import": "./dist/plugin-mention.js" } }, "types": "./src/index.ts", diff --git a/packages/shared/src/plugin-mention.ts b/packages/shared/src/plugin-mention.ts new file mode 100644 index 00000000..d738380b --- /dev/null +++ b/packages/shared/src/plugin-mention.ts @@ -0,0 +1,40 @@ +/** Durable plugin identity shared by Composer history and Runtime selection. */ +export function buildPluginId(name: string, source: 'official' | 'local'): string { + return `${name.trim()}@${source}`; +} + +export interface PluginMention { + readonly pluginId: string; + readonly label: string; + readonly start: number; + readonly end: number; +} + +export function serializePluginMention(pluginId: string, label: string): string { + const name = label + .replace(/^@/u, '') + .replace(/[\[\]\r\n\\]/gu, ' ') + .slice(0, 256); + return `[@${name}](plugin://${encodeURIComponent(pluginId)})`; +} + +/** Linked references also work in exec and ACP text inputs. Display names never select identity. */ +export function parsePluginMentions(text: string): PluginMention[] { + const result: PluginMention[] = []; + for (const match of text.matchAll(/\[@([^\]\r\n]{1,256})\]\(plugin:\/\/([^\s()]{1,768})\)/gu)) { + let pluginId: string; + try { + pluginId = decodeURIComponent(match[2]!); + } catch { + continue; + } + if (!pluginId || pluginId.length > 512 || /[\u0000-\u001f\u007f]/u.test(pluginId)) continue; + result.push({ + pluginId, + label: `@${match[1]}`, + start: match.index, + end: match.index + match[0].length, + }); + } + return result; +} diff --git a/packages/tui/src/application/run-coordinator.ts b/packages/tui/src/application/run-coordinator.ts index bfdcda4e..211a1eb2 100644 --- a/packages/tui/src/application/run-coordinator.ts +++ b/packages/tui/src/application/run-coordinator.ts @@ -28,6 +28,7 @@ export interface TuiRunRequest { turnId: string; session: Promise; content: string; + displayContent?: string; workspace: string; version: string; attachments?: readonly TuiTransportAttachment[]; @@ -406,6 +407,7 @@ function toSendMessageRequest( id: session.sessionId, turnId: request.turnId, content: request.content, + ...(request.displayContent !== undefined ? { displayContent: request.displayContent } : {}), ...(executionDeadlineAtMs !== undefined ? { executionDeadlineAtMs } : {}), ...(request.executionDiagnostics ? { executionDiagnostics: true } : {}), ...(request.clientIntent ? { clientIntent: request.clientIntent } : {}), diff --git a/packages/tui/src/runtime/adapters/plugin-access.ts b/packages/tui/src/runtime/adapters/plugin-access.ts index 3b857017..035ec738 100644 --- a/packages/tui/src/runtime/adapters/plugin-access.ts +++ b/packages/tui/src/runtime/adapters/plugin-access.ts @@ -1,3 +1,4 @@ +import { buildPluginId } from '@mavis/shared/plugin-mention'; import type { CliService, InstalledPluginSummary, @@ -124,7 +125,7 @@ function toPluginView( const name = plugin.name.trim(); if (!name) return undefined; return { - pluginId: `${name}@${marketplace}`, + pluginId: buildPluginId(name, marketplace), name, displayName: plugin.displayName?.trim() || name, marketplace, diff --git a/packages/tui/src/runtime/stream-events.ts b/packages/tui/src/runtime/stream-events.ts index 5644b107..55ca8996 100644 --- a/packages/tui/src/runtime/stream-events.ts +++ b/packages/tui/src/runtime/stream-events.ts @@ -44,6 +44,7 @@ export type TuiMessagePart = }; export interface TuiMessage { + editContent?: string; id?: string; turnId?: string; role: TuiMessageRole; @@ -333,6 +334,7 @@ function normalizeMessage(message: Record, fallbackTurnId?: str tokensBefore: readNumber(message, ['tokensBefore', 'tokens_before']), tokensAfter: readNumber(message, ['tokensAfter', 'tokens_after']), content: readString(message, ['msg_content', 'msgContent', 'content']), + editContent: readString(message, ['editContent']), thinking: readString(message, ['thinking_content', 'thinkingContent', 'thinking']), thinkingDurationMs: readNumber(message, ['thinking_duration_ms', 'thinkingDurationMs']), toolCalls: normalizeToolCalls(readArray(message, ['tool_calls', 'toolCalls'])), diff --git a/packages/tui/src/tui/commands/plugin-autocomplete.ts b/packages/tui/src/tui/commands/plugin-autocomplete.ts new file mode 100644 index 00000000..abe63065 --- /dev/null +++ b/packages/tui/src/tui/commands/plugin-autocomplete.ts @@ -0,0 +1,113 @@ +import type { McodePluginRuntimeAccess } from '../../plugin/contract.js'; +import type { AutocompleteItem, AutocompleteProvider } from '../widgets/autocomplete.js'; +import { truncateToWidth } from '../engine/public.js'; +import { sanitizeTerminalText } from '../rendering/terminal-text.js'; + +export interface PluginAutocompleteItem extends AutocompleteItem { + readonly pluginId: string; + readonly groupLabel: string; +} + +/** Product-level plugin candidates share @ with files; plugin state stays in Runtime. */ +export class TuiPluginAutocomplete implements AutocompleteProvider { + readonly triggerCharacters = ['@']; + + constructor( + private readonly base: AutocompleteProvider, + private readonly plugins?: Partial>, + ) {} + + async getSuggestions( + lines: string[], + cursorLine: number, + cursorCol: number, + options: { signal: AbortSignal; force?: boolean }, + ) { + const before = (lines[cursorLine] ?? '').slice(0, cursorCol); + const prefix = /(?:^|\s)(@[^\s"/]*)$/u.exec(before)?.[1]; + if ( + !prefix || + lines.join('\n').trimStart().startsWith('!') || + !this.plugins?.listInstalledPlugins + ) { + return this.base.getSuggestions(lines, cursorLine, cursorCol, options); + } + const [files, plugins] = await Promise.allSettled([ + this.base.getSuggestions(lines, cursorLine, cursorCol, options), + this.plugins.listInstalledPlugins(), + ]); + if (options.signal.aborted) return null; + const fileSuggestions = files.status === 'fulfilled' ? files.value : null; + const query = prefix.slice(1).normalize('NFKC').toLocaleLowerCase(); + const items: PluginAutocompleteItem[] = + plugins.status === 'fulfilled' + ? plugins.value + .filter( + (plugin) => + plugin.installed && + plugin.enabled && + [plugin.name, plugin.displayName, plugin.pluginId].some((text) => + text.normalize('NFKC').toLocaleLowerCase().includes(query), + ), + ) + .sort( + (a, b) => + a.displayName.localeCompare(b.displayName) || a.pluginId.localeCompare(b.pluginId), + ) + .map((plugin) => { + const name = + sanitizeTerminalText(plugin.displayName) + .replace(/[\[\]\\]/gu, ' ') + .replace(/\s+/gu, ' ') + .trim() + .slice(0, 256) || plugin.name; + return { + value: `@${name}`, + label: `@${name}`, + pluginId: plugin.pluginId, + groupLabel: ' Plugins', + description: `${plugin.marketplace} · ${truncateToWidth( + sanitizeTerminalText(plugin.description ?? plugin.name) + .replace(/\s+/gu, ' ') + .trim(), + 64, + '…', + )}`, + }; + }) + : []; + const combined = [ + ...items, + ...(fileSuggestions?.items ?? []).map((item) => ({ + ...item, + ...(items.length > 0 ? { groupLabel: ' Files' } : {}), + })), + ]; + return combined.length ? { prefix, items: combined } : null; + } + + applyCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ) { + if (!('pluginId' in item)) + return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix); + const line = lines[cursorLine] ?? ''; + const start = cursorCol - prefix.length; + const insertion = `${item.value} `; + const next = [...lines]; + next[cursorLine] = line.slice(0, start) + insertion + line.slice(cursorCol); + return { lines: next, cursorLine, cursorCol: start + insertion.length }; + } + + shouldAutoTriggerCompletion(lines: string[], cursorLine: number, cursorCol: number) { + return this.base.shouldAutoTriggerCompletion?.(lines, cursorLine, cursorCol); + } + + shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number) { + return this.base.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? false; + } +} diff --git a/packages/tui/src/tui/controller/chat-controller.ts b/packages/tui/src/tui/controller/chat-controller.ts index abd1b851..f7724640 100644 --- a/packages/tui/src/tui/controller/chat-controller.ts +++ b/packages/tui/src/tui/controller/chat-controller.ts @@ -435,6 +435,7 @@ export class TuiChatController { turnId, session, content, + ...(options.displayContent !== undefined ? { displayContent } : {}), workspace: this.workspaceDir, version: this.version, ...(attachments.length > 0 ? { attachments } : {}), diff --git a/packages/tui/src/tui/controller/interaction/input-flow.ts b/packages/tui/src/tui/controller/interaction/input-flow.ts index 75509bb3..612196e7 100644 --- a/packages/tui/src/tui/controller/interaction/input-flow.ts +++ b/packages/tui/src/tui/controller/interaction/input-flow.ts @@ -109,6 +109,7 @@ export class TuiInputFlow { item, ) => { previousAutocompleteSelect?.(suggestions, item); + if ('pluginId' in item) return; const imageReference = selectedImageMentionReference(item.value, suggestions.prefix); if (!imageReference) return; void this.options.composerDraft.queueAttachment(imageReference).catch((error: unknown) => @@ -505,6 +506,7 @@ export class TuiInputFlow { this.options.editor.restoreDraft({ ...this.clearedEditorDraft, text: '', + pluginMentions: [], cursor: 0, pastes: [], attachmentPlaceholders: [], diff --git a/packages/tui/src/tui/controller/product/command-flow.ts b/packages/tui/src/tui/controller/product/command-flow.ts index 43b93fdc..1b00837f 100644 --- a/packages/tui/src/tui/controller/product/command-flow.ts +++ b/packages/tui/src/tui/controller/product/command-flow.ts @@ -1,3 +1,4 @@ +import { parsePluginMentions } from '@mavis/shared/plugin-mention'; import { createTuiCommandCatalog, matchTuiCommandInput, @@ -12,7 +13,7 @@ import { TuiLoginRegionPicker } from '../../features/auth/login-region-picker.js import { TuiPermissionModePicker } from '../../features/interaction/permission-mode-picker.js'; import { TuiSettingsPicker } from '../../features/settings/picker.js'; import { TuiHotkeysPicker } from '../../features/settings/hotkeys-picker.js'; -import { submittedEditorContent, type Editor } from '../../widgets/editor/editor.js'; +import { submittedEditorContent, submittedEditorTransport, type Editor } from '../../widgets/editor/editor.js'; import type { TuiInteractionSurface } from '../../shell/interaction-surface.js'; import type { TuiSurfaceHost } from '../../shell/surface-host.js'; import type { TuiRunProjection } from '../../state/run-projection.js'; @@ -226,10 +227,14 @@ export class TuiCommandFlow { // Hidden context may be rebuilt for an edited session-mutation message. // Other opaque transport (including /review) belongs to the old text and // must not override the user's correction. - const transportContent = recoverable?.transportContent && + const pluginTransport = recoverable?.transportContent && parsePluginMentions(recoverable.transportContent).length > 0 + ? rebuildSessionMutationTransport(recoverable.transportContent, submittedEditorTransport(editor) ?? visibleContent) + : undefined; + const transportContent = pluginTransport ?? (recoverable?.transportContent && + parsePluginMentions(recoverable.transportContent).length === 0 && (unchangedText || rebuildSessionMutationTransport(recoverable.transportContent, visibleContent)) ? recoverable.transportContent - : undefined; + : undefined); const transportAttachments = recoverable?.transportAttachments ? reconcileRecoveredTransportAttachments(recoverable, resources.attachments) : undefined; @@ -273,7 +278,7 @@ export class TuiCommandFlow { requireRuntimeAcceptance: true, transportContent: options.transportContent ?? - rebuildSessionMutationTransport(recoveryTransport, input) ?? + rebuildSessionMutationTransport(recoveryTransport, seed ? submittedEditorTransport(seed.editor) ?? input : input) ?? recoveryTransport, ...(seed?.reviewRequest && !options.reviewRequest ? { reviewRequest: seed.reviewRequest } @@ -507,9 +512,11 @@ export class TuiCommandFlow { const bashContext = this.options.bashFlow?.takeContext( seed ? seed.sessionId : this.options.controller.snapshot().session?.sessionId, ); + const boundContent = seed ? submittedEditorTransport(seed.editor) : undefined; + const messageContent = options.transportContent ?? boundContent; const transportContent = bashContext - ? `${bashContext}\n\n${options.transportContent ?? command}` - : options.transportContent; + ? `${bashContext}\n\n${messageContent ?? command}` + : messageContent; let submission = createTuiSubmissionSnapshot({ submissionId, sessionId: seed ? seed.sessionId : this.options.controller.snapshot().session?.sessionId, diff --git a/packages/tui/src/tui/controller/product/session-mutation-flow.ts b/packages/tui/src/tui/controller/product/session-mutation-flow.ts index 10f95a6e..9ffc66d3 100644 --- a/packages/tui/src/tui/controller/product/session-mutation-flow.ts +++ b/packages/tui/src/tui/controller/product/session-mutation-flow.ts @@ -1,3 +1,4 @@ +import { submittedEditorTransport } from '../../widgets/editor/editor.js'; import type { TuiChatController } from '../chat-controller.js'; import type { Component, Focusable } from '../../rendering/component.js'; import { matchesKey } from '../../engine/public.js'; @@ -415,8 +416,12 @@ export class TuiSessionMutationFlow { this.options.onChanged(); return 'retained'; } + const boundContent = draft ? submittedEditorTransport(draft) ?? content : content; const transportContent = - rebuildSessionMutationTransport(invocation.targetMessage?.content, content) ?? content; + rebuildSessionMutationTransport( + invocation.targetMessage?.editContent ?? invocation.targetMessage?.content, + boundContent, + ) ?? boundContent; if (invocation.phase === 'resubmit') { this.invocations.set(invocation.sequence, { ...invocation, phase: 'resubmitting' }); this.options.setHint(sessionMutationText('sessionMutation.hint.editSubmitting')); @@ -658,7 +663,9 @@ export class TuiSessionMutationFlow { this.closeHistoryScreen(); this.options.setEditTranscriptBoundary?.(targetMessage.id); this.invocations.set(sequence, { ...invocation, targetMessage, phase: 'editing' }); - const content = visibleSessionMutationContent(targetMessage.content); + const content = visibleSessionMutationContent( + targetMessage.editContent ?? targetMessage.content, + ); const placeholders = this.editAttachmentEntries(); if (placeholders.length > 0) this.options.editor.restoreMessageDraft(content, placeholders); else this.options.editor.setText(content); diff --git a/packages/tui/src/tui/controller/projection/turn-user-projection.ts b/packages/tui/src/tui/controller/projection/turn-user-projection.ts index 7bf3910c..957d0c1c 100644 --- a/packages/tui/src/tui/controller/projection/turn-user-projection.ts +++ b/packages/tui/src/tui/controller/projection/turn-user-projection.ts @@ -1,3 +1,4 @@ +import { parsePluginMentions } from '@mavis/shared/plugin-mention'; import type { TuiMessage } from '../../../runtime/port.js'; import { formatTuiHistorySubmission, @@ -34,7 +35,7 @@ export class TuiUserProjection { return; } const visibleContent = - message.source === 'code_review' ? '/review' : visibleSessionMutationContent(content); + message.source === 'code_review' ? '/review' : visibleUserContent(content); this.transcript.upsert({ id: `history:user:${messageId}`, kind: 'user', @@ -69,7 +70,7 @@ export class TuiUserProjection { return false; } const formatted = formatTuiHistorySubmission( - message.source === 'code_review' ? '/review' : visibleSessionMutationContent(content), + message.source === 'code_review' ? '/review' : visibleUserContent(content), message.attachments ?? [], ); const attachments = toTuiTranscriptAttachments(message.attachments ?? []); @@ -187,3 +188,12 @@ function mergeTranscriptAttachments( ...attachment, })); } + +/** Older persisted rows may still contain the durable plugin transport links. */ +function visibleUserContent(content: string): string { + let visible = visibleSessionMutationContent(content); + for (const mention of parsePluginMentions(visible).reverse()) { + visible = visible.slice(0, mention.start) + mention.label + visible.slice(mention.end); + } + return visible; +} diff --git a/packages/tui/src/tui/controller/run/active-run-flow.ts b/packages/tui/src/tui/controller/run/active-run-flow.ts index 2604786d..333083bb 100644 --- a/packages/tui/src/tui/controller/run/active-run-flow.ts +++ b/packages/tui/src/tui/controller/run/active-run-flow.ts @@ -1,3 +1,5 @@ +import { TuiPluginAutocomplete } from '../../commands/plugin-autocomplete.js'; +import type { McodePluginRuntimeAccess } from '../../../plugin/contract.js'; import type { Component } from '../../rendering/component.js'; import type { Terminal } from '../../engine/public.js'; import { @@ -57,7 +59,9 @@ export interface TuiSteerOptions { export function createTuiInitialAutocomplete( workspace: string | readonly TuiWorkspaceRoot[], - workspaceFiles?: Partial, + workspaceFiles?: Partial< + TuiWorkspaceFilePort & Pick + >, builtInCommands: readonly TuiCommand[] = MINIMAX_CODE_DISCOVERABLE_COMMANDS, ) { return createTuiAutocomplete(builtInCommands, [], workspace, workspaceFiles); @@ -67,15 +71,20 @@ export function createTuiAutocomplete( builtInCommands: readonly TuiCommand[], skillCommands: readonly TuiCommand[], workspace: string | readonly TuiWorkspaceRoot[], - workspaceFiles?: Partial, + workspaceFiles?: Partial< + TuiWorkspaceFilePort & Pick + >, shellCwd?: () => string, ): AutocompleteProvider { - return new TuiAutocompleteProvider( - builtInCommands, - skillCommands, - workspace, + return new TuiPluginAutocomplete( + new TuiAutocompleteProvider( + builtInCommands, + skillCommands, + workspace, + workspaceFiles, + shellCwd, + ), workspaceFiles, - shellCwd, ); } @@ -132,11 +141,9 @@ export class TuiActiveRunFlow { private autocompleteHasLiveRun: boolean | undefined; private autocompleteSignature: string | undefined; private pendingAutocomplete: - | { readonly provider: AutocompleteProvider; readonly signature: string } - | undefined; + { readonly provider: AutocompleteProvider; readonly signature: string } | undefined; private contextInspection: - | { readonly sessionId: string; readonly panel: TuiReportInspectionPanel } - | undefined; + { readonly sessionId: string; readonly panel: TuiReportInspectionPanel } | undefined; constructor( private readonly options: { @@ -569,7 +576,9 @@ class TuiAutocompleteProvider implements AutocompleteProvider { builtInCommands: readonly TuiCommand[], skillCommands: readonly TuiCommand[], workspace: string | readonly TuiWorkspaceRoot[], - private readonly workspaceFiles?: Partial, + private readonly workspaceFiles?: Partial< + TuiWorkspaceFilePort & Pick + >, shellCwd?: () => string, ) { this.workspaceRoots = normalizeAutocompleteRoots(workspace); @@ -732,7 +741,10 @@ class TuiAutocompleteProvider implements AutocompleteProvider { const directory = query ? query.slice(0, -1) : undefined; if (this.workspaceFiles?.listWorkspaceFileTreeCandidates) { const entries = await this.workspaceFiles.listWorkspaceFileTreeCandidates( - { roots: this.workspaceRoots, ...(directory ? { path: directory } : {}) }, + { + roots: this.workspaceRoots, + ...(directory ? { path: directory } : {}), + }, signal, ); return entries.flatMap((entry) => this.toMultiRootTreeItem(entry)); diff --git a/packages/tui/src/tui/controller/run/queue-flow.ts b/packages/tui/src/tui/controller/run/queue-flow.ts index 7632f22b..3a87c34f 100644 --- a/packages/tui/src/tui/controller/run/queue-flow.ts +++ b/packages/tui/src/tui/controller/run/queue-flow.ts @@ -1,3 +1,4 @@ +import { decodePluginMentions, encodePluginMentions, transformPluginMentions } from '../../widgets/editor/plugin-mentions.js'; import type { TuiModelSelection, TuiQueuePort, @@ -301,6 +302,12 @@ export class TuiQueueFlow { ? (submission?.transportContent ?? rememberedTransport) : (item.content ?? submission?.transportContent ?? rememberedTransport); if (!transportContent) return item; + const decoded = decodePluginMentions(visibleSessionMutationContent(transportContent)); + if (decoded.mentions.length) { + this.queuedTransportContents.set(item.itemId, transportContent); + return { ...item, content: decoded.text }; + } + if (rememberedTransport && decodePluginMentions(visibleSessionMutationContent(rememberedTransport)).text === item.content) return item; if (item.reviewRequest) { this.queuedTransportContents.set(item.itemId, transportContent); return { ...item, content: '/review' }; @@ -575,8 +582,11 @@ export class TuiQueueFlow { } const sessionId = this.options.controller.snapshot().session?.sessionId; if (!sessionId) return false; - const transportContent = - rebuildSessionMutationTransport(this.queuedTransportContents.get(itemId), content) ?? content; + const previousTransport = this.queuedTransportContents.get(itemId); + const previous = decodePluginMentions(visibleSessionMutationContent(previousTransport ?? cached?.content)); + const mentions = transformPluginMentions(previous.text, content, previous.mentions); + const boundContent = encodePluginMentions(content, mentions); + const transportContent = rebuildSessionMutationTransport(previousTransport, boundContent) ?? boundContent; const updated = await this.options.runtime.updateQueuedMessageContent( sessionId, itemId, @@ -588,6 +598,8 @@ export class TuiQueueFlow { await this.refresh(sessionId); return false; } + if (transportContent !== content) this.queuedTransportContents.set(itemId, transportContent); + else this.queuedTransportContents.delete(itemId); const captured = this.queuedSubmissions.get(itemId); if (captured) { this.queuedSubmissions.set(itemId, { @@ -596,6 +608,7 @@ export class TuiQueueFlow { schemaVersion: 1, text: content, cursor: content.length, + pluginMentions: mentions, pastes: [], pasteCounter: 0, }, @@ -792,7 +805,8 @@ function createSubmissionFromQueuedMessage( submissionId: string, transportContent?: string, ): TuiSubmissionSnapshot { - const content = item.content ?? ''; + const decoded = decodePluginMentions(visibleSessionMutationContent(transportContent ?? item.content)); + const content = decoded.text; const transportAttachments = (item.attachments ?? []).flatMap( (attachment) => { const filePath = attachment.local?.filePath; @@ -816,12 +830,13 @@ function createSubmissionFromQueuedMessage( editor: { schemaVersion: 1, text: content, + pluginMentions: decoded.mentions, cursor: content.length, pastes: [], pasteCounter: 0, }, content, - ...(transportContent ? { transportContent } : {}), + ...(transportContent || decoded.mentions.length ? { transportContent: transportContent ?? item.content } : {}), attachments: (item.attachments ?? []).flatMap((attachment) => { const filePath = attachment.local?.filePath; if (!filePath) return []; diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.json b/packages/tui/src/tui/engine/LOCAL_CHANGES.json index f39b20b7..690550ab 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.json +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.json @@ -21,9 +21,9 @@ }, { "path": "components/editor.ts", - "currentSha256": "0775a490df136d1083738cce649e026d6153ecb5480715b2c1104d51051968c5", - "changeIds": ["L005", "L009", "L022", "L030", "L032"], - "reason": "Keep strict TypeScript fixes and add the smallest generic snapshot, paste, telemetry, programmatic submission, undo-extension and empty-placeholder hooks needed by the MCode product wrapper. 提供方通过上下文回调控制自动触发,并通过 applyOnEnter 控制 Enter 是否填入候选。 强制补全上下文失效时立即取消请求并清空菜单。 支持单帧 placeholder 覆盖,由 Editor 统一保留 padding、可见光标和 IME 定位标记。 命令参数候选标记阶段,接受命令后续查参数,并隔离参数回调失败。", + "currentSha256": "55d313e4f7f5760212e0ff397cb7a599c52d42a90df8c0014f25cf4652897d20", + "changeIds": ["L005", "L009", "L022", "L030", "L032", "L042"], + "reason": "Preserve product history metadata through a generic text transform and draft extension state. Keep strict TypeScript fixes and add the smallest generic snapshot, paste, telemetry, programmatic submission, undo-extension and empty-placeholder hooks needed by the MCode product wrapper. 提供方通过上下文回调控制自动触发,并通过 applyOnEnter 控制 Enter 是否填入候选。 强制补全上下文失效时立即取消请求并清空菜单。 支持单帧 placeholder 覆盖,由 Editor 统一保留 padding、可见光标和 IME 定位标记。 命令参数候选标记阶段,接受命令后续查参数,并隔离参数回调失败。", "behaviorImpact": "MCode Draft, attachment, product-level submission-intent and empty-input guidance semantics use the canonical Pi Editor cursor, width, padding, history, undo, paste-marker and autocomplete behavior. Shell 补全只由 Tab 打开,菜单内输入继续过滤;Enter 执行当前输入,Tab 填入选中项。其他提供方沿用默认行为。 删除 Shell 标记或命令名前缀后立即关闭候选;已取消请求的延迟结果无法恢复旧菜单。 带参数候选的命令支持连续补全,参数 Enter 仅填入,Esc 保留草稿;无参数命令保持原行为。" }, { diff --git a/packages/tui/src/tui/engine/LOCAL_CHANGES.md b/packages/tui/src/tui/engine/LOCAL_CHANGES.md index 50b72941..b240a9f3 100644 --- a/packages/tui/src/tui/engine/LOCAL_CHANGES.md +++ b/packages/tui/src/tui/engine/LOCAL_CHANGES.md @@ -170,3 +170,10 @@ Remove `L024` when the selected Pi baseline natively matches legacy-terminal `Ct - Evidence: application tests replay `/theme`, `/settings`, prompt-history search, image-preview dismissal, multi-line draft clearing and completion filtering. Engine tests repeatedly expand/shrink each transient section under xterm and an ED 2 clear-to-scrollback model, compare the complete viewport, verify unique history, and retain positive background-activity scroll preservation. Short documents avoid unnecessary clearing. - Boundary: full history reconstruction retains L034's shell-scrollback tradeoff. Emulator tests do not establish native terminal or live-service acceptance. - Removal condition: the selected Pi baseline distinguishes transient UI layout shrink from ordinary background content shrink. + +## L042: Preserve product mention bindings in prompt history + +- Product contract: plugin labels retain their exact identities while browsing history, including restoration of the working draft. +- Minimal difference: expose a generic history-text decoder and capture/restore the existing undo extension state alongside the history draft. Plugin parsing and identity ownership stay in the product Editor. +- Evidence: `tui-plugin-mentions.test.ts` covers repeated history navigation, identical display labels with different IDs, working-draft restoration, atomic deletion and undo. +- Removal condition: the selected Pi baseline supports durable history decoding and draft extension state. diff --git a/packages/tui/src/tui/engine/components/editor.ts b/packages/tui/src/tui/engine/components/editor.ts index 999096c7..5c8a5657 100644 --- a/packages/tui/src/tui/engine/components/editor.ts +++ b/packages/tui/src/tui/engine/components/editor.ts @@ -356,6 +356,7 @@ export class Editor implements Component, Focusable { private history: string[] = []; private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. private historyDraft: EditorState | null = null; + private historyDraftExtensionState: unknown; // Kill ring for Emacs-style kill/yank operations private killRing = new KillRing(); @@ -377,6 +378,8 @@ export class Editor implements Component, Focusable { // Undo support private undoStack = new UndoStack(); + /** Decode a durable history entry before restoring its editable text. */ + public transformHistoryText?: (text: string) => string; public onSubmit?: (text: string, snapshot: EditorStateSnapshot) => void; public onChange?: (text: string) => void; public onPaste?: (text: string) => boolean; @@ -496,6 +499,7 @@ export class Editor implements Component, Focusable { if (this.historyIndex === -1 && newIndex >= 0) { this.pushUndoSnapshot(); this.historyDraft = structuredClone(this.state); + this.historyDraftExtensionState = this.captureUndoExtensionState?.(); } this.historyIndex = newIndex; @@ -505,6 +509,8 @@ export class Editor implements Component, Focusable { this.historyDraft = null; if (draft) { this.state = draft; + this.restoreUndoExtensionState?.(this.historyDraftExtensionState); + this.historyDraftExtensionState = undefined; this.preferredVisualCol = null; this.snappedFromCursorCol = null; this.scrollOffset = 0; @@ -514,7 +520,7 @@ export class Editor implements Component, Focusable { } } else { this.setTextInternal( - this.history[this.historyIndex] || '', + this.transformHistoryText?.(this.history[this.historyIndex] || '') ?? this.history[this.historyIndex] ?? '', direction === -1 ? 'start' : 'end', ); } @@ -523,6 +529,7 @@ export class Editor implements Component, Focusable { private exitHistoryBrowsing(): void { this.historyIndex = -1; this.historyDraft = null; + this.historyDraftExtensionState = undefined; } /** Internal setText that doesn't reset history state - used by navigateHistory */ @@ -1221,6 +1228,8 @@ export class Editor implements Component, Focusable { this.onAutocompleteSelect = undefined; this.captureUndoExtensionState = undefined; this.restoreUndoExtensionState = undefined; + this.transformHistoryText = undefined; + this.historyDraftExtensionState = undefined; } /** diff --git a/packages/tui/src/tui/features/composer/draft-recovery.ts b/packages/tui/src/tui/features/composer/draft-recovery.ts index 0e8a829e..505198dd 100644 --- a/packages/tui/src/tui/features/composer/draft-recovery.ts +++ b/packages/tui/src/tui/features/composer/draft-recovery.ts @@ -1,3 +1,4 @@ +import { validPluginMentions } from '../../widgets/editor/plugin-mentions.js'; import { createHash } from 'node:crypto'; import { chmod, @@ -431,7 +432,10 @@ export class TuiDraftRecovery { const relocatedPath = attachment.filePath ? relocatedPaths.get(attachment.filePath) : undefined; - return { ...attachment, ...(relocatedPath ? { filePath: relocatedPath } : {}) }; + return { + ...attachment, + ...(relocatedPath ? { filePath: relocatedPath } : {}), + }; }), } : {}), @@ -450,7 +454,9 @@ export class TuiDraftRecovery { .map((attachment) => resolve(attachment.filePath)) .filter((filePath) => dirname(filePath) === resolve(this.assetsDirectory)), ); - const entries = await readdir(this.assetsDirectory, { withFileTypes: true }).catch(() => []); + const entries = await readdir(this.assetsDirectory, { + withFileTypes: true, + }).catch(() => []); await Promise.all( entries.map(async (entry) => { if (!entry.isFile()) return; @@ -509,6 +515,7 @@ function cloneDraft(draft: TuiRecoverableDraft): TuiRecoverableDraft { return { editor: { ...draft.editor, + pluginMentions: draft.editor.pluginMentions?.map((mention) => ({ ...mention })), pastes: draft.editor.pastes.map((paste) => ({ ...paste })), ...(draft.editor.attachmentPlaceholders ? { @@ -538,7 +545,9 @@ function cloneRetrySubmission(retry: TuiRetrySubmission): TuiRetrySubmission { ...(retry.snapshot.transportContent ? { transportContent: retry.snapshot.transportContent } : {}), - attachments: retry.snapshot.attachments.map((attachment) => ({ ...attachment })), + attachments: retry.snapshot.attachments.map((attachment) => ({ + ...attachment, + })), ...(retry.snapshot.transportAttachments ? { transportAttachments: retry.snapshot.transportAttachments.map((attachment) => ({ @@ -556,6 +565,7 @@ function cloneRetrySubmission(retry: TuiRetrySubmission): TuiRetrySubmission { function cloneEditorDraft(editor: EditorDraftSnapshot): EditorDraftSnapshot { return { ...editor, + pluginMentions: editor.pluginMentions?.map((mention) => ({ ...mention })), pastes: editor.pastes.map((paste) => ({ ...paste })), ...(editor.attachmentPlaceholders ? { @@ -749,6 +759,7 @@ function isEditorDraft(value: unknown): value is EditorDraftSnapshot { !isRecord(value) || value.schemaVersion !== 1 || typeof value.text !== 'string' || + !validPluginMentions(value.text, value.pluginMentions) || !Number.isInteger(value.cursor) || (value.cursor as number) < 0 || (value.cursor as number) > value.text.length || diff --git a/packages/tui/src/tui/features/composer/submission.ts b/packages/tui/src/tui/features/composer/submission.ts index 85075427..5f904fb8 100644 --- a/packages/tui/src/tui/features/composer/submission.ts +++ b/packages/tui/src/tui/features/composer/submission.ts @@ -51,7 +51,9 @@ export function createTuiSubmissionSnapshot(options: { editor: cloneEditorDraft(options.editor), content: options.content, ...(options.transportContent ? { transportContent: options.transportContent } : {}), - attachments: options.resources.attachments.map((attachment) => ({ ...attachment })), + attachments: options.resources.attachments.map((attachment) => ({ + ...attachment, + })), transportAttachments: (options.transportAttachments ?? options.resources.attachments).map( (attachment) => ({ ...attachment }), ), @@ -64,6 +66,7 @@ export function createTuiSubmissionSnapshot(options: { function cloneEditorDraft(editor: EditorDraftSnapshot): EditorDraftSnapshot { return { ...editor, + pluginMentions: editor.pluginMentions?.map((mention) => ({ ...mention })), pastes: editor.pastes.map((paste) => ({ ...paste })), ...(editor.attachmentPlaceholders ? { diff --git a/packages/tui/src/tui/features/history/search-panel.ts b/packages/tui/src/tui/features/history/search-panel.ts index 0a568d69..cc2aad2e 100644 --- a/packages/tui/src/tui/features/history/search-panel.ts +++ b/packages/tui/src/tui/features/history/search-panel.ts @@ -1,3 +1,4 @@ +import { decodePluginMentions } from '../../widgets/editor/plugin-mentions.js'; import { Input, matchesKey } from '../../engine/public.js'; import type { Component, Focusable } from '../../rendering/component.js'; import { truncateToWidth, visibleWidth } from '../../rendering/text.js'; @@ -67,8 +68,8 @@ export class TuiHistorySearchPanel implements Component, Focusable { const selected = index === this.selectedIndex; const prefix = selected ? chalk.bold.hex(colors.signal)('› ') : ' '; const value = selected - ? chalk.bold.hex(colors.text)(sanitizeTerminalText(entry)) - : chalk.hex(colors.muted)(sanitizeTerminalText(entry)); + ? chalk.bold.hex(colors.text)(sanitizeTerminalText(decodePluginMentions(entry).text)) + : chalk.hex(colors.muted)(sanitizeTerminalText(decodePluginMentions(entry).text)); return fit(`${prefix}${value}`, safeWidth); }) : [fit(chalk.hex(colors.muted)(' No matching prompts'), safeWidth)]; @@ -82,7 +83,7 @@ export class TuiHistorySearchPanel implements Component, Focusable { private matches(): string[] { const query = this.searchInput.getValue().trim().toLocaleLowerCase(); if (!query) return [...this.options.entries]; - return this.options.entries.filter((entry) => entry.toLocaleLowerCase().includes(query)); + return this.options.entries.filter((entry) => decodePluginMentions(entry).text.toLocaleLowerCase().includes(query)); } private move(direction: -1 | 1): void { diff --git a/packages/tui/src/tui/shell/composer.ts b/packages/tui/src/tui/shell/composer.ts index 4cb207c2..f6e1fbe3 100644 --- a/packages/tui/src/tui/shell/composer.ts +++ b/packages/tui/src/tui/shell/composer.ts @@ -260,7 +260,7 @@ function composerLabels( return [`${attachment} · ${submit} send`, attachment, 'Message']; } if (state.surface === 'welcome') { - return ['Start · @ file · / autocomplete', 'Start below']; + return ['Start · @ file or Plugin · / autocomplete', 'Start below']; } const newline = composerNewlineKeybinding(supportsShiftEnter, keybindings); return [`Message · ${submit} send · ${newline} newline`, `Message · ${submit} send`, 'Message']; diff --git a/packages/tui/src/tui/theme/custom-themes.ts b/packages/tui/src/tui/theme/custom-themes.ts index 453a8c72..afb59ae6 100644 --- a/packages/tui/src/tui/theme/custom-themes.ts +++ b/packages/tui/src/tui/theme/custom-themes.ts @@ -288,6 +288,9 @@ export function watchCustomThemes( try { watcher = watch(directory, { persistent: false }, fire); watcher.on('error', () => undefined); + // Native watching may miss edits made while it starts (notably on macOS). + // Reconcile once after registration, then use the same debounced event path. + fire(); } catch { watcher = undefined; } diff --git a/packages/tui/src/tui/widgets/editor/editor.ts b/packages/tui/src/tui/widgets/editor/editor.ts index f81e3eff..e7ee277f 100644 --- a/packages/tui/src/tui/widgets/editor/editor.ts +++ b/packages/tui/src/tui/widgets/editor/editor.ts @@ -1,3 +1,11 @@ +import { + decodePluginMentions, + encodePluginMentions, + transformPluginMentions, + transformExternalPluginMentions, + validPluginMentions, + type EditorPluginMention, +} from './plugin-mentions.js'; import type { AutocompleteProvider, AutocompleteSuggestions } from '../autocomplete.js'; import { decodePrintableKey, @@ -20,6 +28,7 @@ export type { EditorTheme }; export interface EditorDraftSnapshot extends EditorStateSnapshot { readonly attachmentPlaceholders?: readonly EditorAttachmentElement[]; + readonly pluginMentions?: readonly EditorPluginMention[]; } export interface EditorAttachmentPlaceholder { @@ -46,6 +55,11 @@ export class Editor implements Component, Focusable { ) => void; private readonly engine: PiEditor; + private pluginMentions: EditorPluginMention[] = []; + private pendingPluginMention: EditorPluginMention | undefined; + private pendingPluginState: EditorPluginMention[] | undefined; + private skipNextPluginTransform = false; + private lastPluginSubmission: { content: string; transport: string } | undefined; private attachmentPlaceholders = new Map(); private bindLegacyAttachmentPlaceholders = false; private lastText = ''; @@ -70,10 +84,39 @@ export class Editor implements Component, Focusable { this.engine.onSubmit = (_text, snapshot) => this.handleEngineSubmit(snapshot); this.engine.onPaste = (text) => this.onPaste?.(text) ?? false; this.engine.onAutocompleteView = (suggestions) => this.onAutocompleteView?.(suggestions); - this.engine.onAutocompleteSelect = (suggestions, item) => + this.engine.onAutocompleteSelect = (suggestions, item) => { + if ('pluginId' in item && typeof item.pluginId === 'string') { + const start = this.engine.captureState().cursor - suggestions.prefix.length; + this.pendingPluginMention = { + pluginId: item.pluginId, + label: item.value, + start, + end: start + item.value.length, + }; + } this.onAutocompleteSelect?.(suggestions, item); - this.engine.captureUndoExtensionState = () => [...this.attachmentPlaceholders.values()]; - this.engine.restoreUndoExtensionState = (state) => this.restoreAttachmentUndoState(state); + }; + this.engine.captureUndoExtensionState = () => ({ + attachments: [...this.attachmentPlaceholders.values()], + plugins: this.pluginMentions.map((mention) => ({ ...mention })), + }); + this.engine.restoreUndoExtensionState = (state) => { + if (!state || typeof state !== 'object' || !('attachments' in state) || !('plugins' in state)) + return; + this.restoreAttachmentUndoState(state.attachments); + if (validPluginMentions(this.getText(), state.plugins)) { + this.pluginMentions = (state.plugins ?? []).map((mention) => ({ + ...mention, + })); + this.skipNextPluginTransform = true; + } + }; + this.engine.transformHistoryText = (text) => { + const decoded = decodePluginMentions(text); + this.pluginMentions = decoded.mentions; + this.skipNextPluginTransform = true; + return decoded.text; + }; } get focused(): boolean { @@ -113,7 +156,15 @@ export class Editor implements Component, Focusable { } addToHistory(text: string): void { - this.engine.addToHistory(text); + const current = this.captureDraft(); + const transport = + this.lastPluginSubmission?.content === text.trim() + ? this.lastPluginSubmission.transport + : submittedEditorContent(current) === text.trim() + ? submittedEditorTransport(current) + : undefined; + this.engine.addToHistory(transport ?? text); + this.lastPluginSubmission = undefined; } getHistoryEntries(): readonly string[] { @@ -139,6 +190,7 @@ export class Editor implements Component, Focusable { captureDraft(): EditorDraftSnapshot { return { ...this.engine.captureState(), + pluginMentions: this.pluginMentions.map((mention) => ({ ...mention })), attachmentPlaceholders: [...this.attachmentPlaceholders.values()].map((element) => ({ ...element, })), @@ -148,6 +200,11 @@ export class Editor implements Component, Focusable { restoreDraft(snapshot: EditorDraftSnapshot): boolean { if (!isValidEditorDraftSnapshot(snapshot)) return false; const previous = this.attachmentPlaceholders; + const previousPlugins = this.pluginMentions; + this.pluginMentions = (snapshot.pluginMentions ?? []).map((mention) => ({ + ...mention, + })); + this.skipNextPluginTransform = true; this.attachmentPlaceholders = new Map( (snapshot.attachmentPlaceholders ?? []).map((element) => [element.id, { ...element }]), ); @@ -157,6 +214,8 @@ export class Editor implements Component, Focusable { const restored = this.engine.restoreState(snapshot); if (!restored) { this.attachmentPlaceholders = previous; + this.pluginMentions = previousPlugins; + this.skipNextPluginTransform = false; this.skipNextAttachmentTransform = false; } return restored; @@ -180,8 +239,18 @@ export class Editor implements Component, Focusable { cursor: currentOffset + remappedCurrent.cursor, pastes: [...snapshot.pastes.map((paste) => ({ ...paste })), ...remappedCurrent.pastes], pasteCounter: remappedCurrent.pasteCounter, + pluginMentions: [ + ...(snapshot.pluginMentions ?? []).map((mention) => ({ ...mention })), + ...(remappedCurrent.pluginMentions ?? []).map((mention) => ({ + ...mention, + start: mention.start + currentOffset, + end: mention.end + currentOffset, + })), + ], attachmentPlaceholders: [ - ...(snapshot.attachmentPlaceholders ?? []).map((element) => ({ ...element })), + ...(snapshot.attachmentPlaceholders ?? []).map((element) => ({ + ...element, + })), ...(remappedCurrent.attachmentPlaceholders ?? []) .filter(({ id }) => !submittedAttachmentIds.has(id)) .map((element) => ({ @@ -239,7 +308,10 @@ export class Editor implements Component, Focusable { dismissAttachmentPreview(): void { const preview = this.getAttachmentPreview(); if (!preview) return; - this.dismissedAttachmentPreview = { id: preview.id, cursor: this.engine.captureState().cursor }; + this.dismissedAttachmentPreview = { + id: preview.id, + cursor: this.engine.captureState().cursor, + }; this.freshAttachmentPreview = undefined; this.tui.requestRender(); } @@ -251,6 +323,7 @@ export class Editor implements Component, Focusable { let cursor = this.engine.captureState().cursor; let changed = false; const elements = cloneAttachmentElements(this.attachmentPlaceholders); + let pluginMentions = this.pluginMentions.map((mention) => ({ ...mention })); if (this.bindLegacyAttachmentPlaceholders) { const claimedRanges: Array<{ start: number; end: number }> = []; for (const [id, label] of next) { @@ -270,7 +343,9 @@ export class Editor implements Component, Focusable { replacement: string, target?: { id: string; label: string }, ): void => { - text = `${text.slice(0, start)}${replacement}${text.slice(end)}`; + const nextText = `${text.slice(0, start)}${replacement}${text.slice(end)}`; + pluginMentions = transformPluginMentions(text, nextText, pluginMentions); + text = nextText; if (cursor > end) cursor += replacement.length - (end - start); else if (cursor > start) cursor = start + replacement.length; const delta = replacement.length - (end - start); @@ -342,10 +417,14 @@ export class Editor implements Component, Focusable { return; } this.pendingAttachmentElements = elements; + this.pendingPluginState = pluginMentions; this.skipNextAttachmentTransform = true; this.engine.replaceRange(0, this.getText().length, text, cursor); if (added) { - this.freshAttachmentPreview = { id: added.id, cursor: this.engine.captureState().cursor }; + this.freshAttachmentPreview = { + id: added.id, + cursor: this.engine.captureState().cursor, + }; this.dismissedAttachmentPreview = undefined; } } @@ -359,16 +438,32 @@ export class Editor implements Component, Focusable { } setText(text: string): void { - const value = text.replace(/\r\n?/gu, '\n'); + const decoded = decodePluginMentions(text.replace(/\r\n?/gu, '\n')); + const value = decoded.text; const replaced = appendAttachmentElements(value, this.attachmentPlaceholders); - if (replaced.text === this.getText()) return; + if (replaced.text === this.getText()) { + if (text !== value) { + this.pluginMentions = decoded.mentions; + this.onChange?.(this.getText()); + } + return; + } + this.pendingPluginMention = undefined; + this.pendingPluginState = decoded.mentions; this.pendingAttachmentElements = replaced.elements; this.skipNextAttachmentTransform = true; this.engine.replaceRange(0, this.getText().length, replaced.text, replaced.cursor); } replaceTextUndoable(text: string): void { - this.setText(text); + // External editors edit expanded visible text, while setText loads a new draft. + const draft = this.captureDraft(); + const previous = decodePluginMentions( + expandDraftPastes(encodePluginMentions(draft.text, this.pluginMentions), draft.pastes), + ); + const normalized = text.replace(/\r\n?/gu, '\n'); + const mentions = transformExternalPluginMentions(previous.text, normalized, previous.mentions); + this.setText(encodePluginMentions(normalized, mentions)); } invalidate(): void { @@ -407,6 +502,7 @@ export class Editor implements Component, Focusable { dispose(): void { this.engine.dispose(); this.attachmentPlaceholders.clear(); + this.pluginMentions = []; this.pendingSubmission = undefined; this.lastAttachmentSubmission = undefined; this.onAttachmentPlaceholderDeleted = undefined; @@ -414,7 +510,21 @@ export class Editor implements Component, Focusable { } private handleEngineChange(text: string): void { + if (this.pendingPluginState) { + this.pluginMentions = this.pendingPluginState; + this.pendingPluginState = undefined; + } else if (this.skipNextPluginTransform) this.skipNextPluginTransform = false; + else this.pluginMentions = transformPluginMentions(this.lastText, text, this.pluginMentions); + if (this.pendingPluginMention) { + if ( + text.slice(this.pendingPluginMention.start, this.pendingPluginMention.end) === + this.pendingPluginMention.label + ) + this.pluginMentions.push(this.pendingPluginMention); + this.pendingPluginMention = undefined; + } if (this.pendingSubmission && text === '') { + this.pluginMentions = []; this.attachmentPlaceholders.clear(); this.lastText = ''; this.skipNextAttachmentTransform = false; @@ -445,12 +555,15 @@ export class Editor implements Component, Focusable { private handleEngineSubmit(snapshot: EditorStateSnapshot): void { const draft: EditorDraftSnapshot = { ...snapshot, + pluginMentions: this.pendingSubmission?.pluginMentions?.map((mention) => ({ ...mention })), attachmentPlaceholders: (this.pendingSubmission?.attachmentPlaceholders ?? []).map( (element) => ({ ...element }), ), }; this.pendingSubmission = undefined; const content = submittedEditorContent(draft); + const transport = submittedEditorTransport(draft); + this.lastPluginSubmission = transport ? { content, transport } : undefined; if (draft.attachmentPlaceholders?.length) this.lastAttachmentSubmission = { content, draft }; this.onSubmit?.(content, draft); } @@ -474,14 +587,24 @@ export class Editor implements Component, Focusable { } } + private atomicElements(): EditorAttachmentElement[] { + return [ + ...this.attachmentPlaceholders.values(), + ...this.pluginMentions.map((mention) => ({ + ...mention, + id: `plugin:${mention.start}`, + })), + ]; + } + private handleAtomicAttachmentInput(data: string): boolean { - if (this.attachmentPlaceholders.size === 0) return false; + if (this.atomicElements().length === 0) return false; const command = resolveAttachmentCommand(data); if (!command || command === 'jump-forward' || command === 'jump-backward') return false; const snapshot = this.engine.captureState(); const cursor = snapshot.cursor; const text = snapshot.text; - const elements = [...this.attachmentPlaceholders.values()]; + const elements = this.atomicElements(); if (command === 'left' || command === 'word-left') { const element = elements.find( (candidate) => cursor > candidate.start && cursor <= attachmentElementEnd(text, candidate), @@ -525,7 +648,7 @@ export class Editor implements Component, Focusable { this.jumpOutsideAttachments(printable, direction); return true; } - if (this.attachmentPlaceholders.size === 0) return false; + if (this.atomicElements().length === 0) return false; const command = resolveAttachmentCommand(data); if (command !== 'jump-forward' && command !== 'jump-backward') return false; this.attachmentJumpMode = command === 'jump-forward' ? 'forward' : 'backward'; @@ -541,7 +664,7 @@ export class Editor implements Component, Focusable { ? snapshot.text.indexOf(character, from) : snapshot.text.lastIndexOf(character, from); if (target < 0) return; - const element = [...this.attachmentPlaceholders.values()].find( + const element = this.atomicElements().find( (candidate) => target >= candidate.start && target < candidate.end, ); if (!element) { @@ -555,7 +678,7 @@ export class Editor implements Component, Focusable { private snapCursorOutsideAttachment(previousCursor: number): void { const snapshot = this.engine.captureState(); - const element = [...this.attachmentPlaceholders.values()].find( + const element = this.atomicElements().find( (candidate) => snapshot.cursor > candidate.start && snapshot.cursor < candidate.end, ); if (!element) return; @@ -623,7 +746,10 @@ function editorDeletionRange( }; } if (command === 'delete-line-start') { - return { start: text.lastIndexOf('\n', Math.max(0, cursor - 1)) + 1, end: cursor }; + return { + start: text.lastIndexOf('\n', Math.max(0, cursor - 1)) + 1, + end: cursor, + }; } const lineEnd = text.indexOf('\n', cursor); return { @@ -652,6 +778,7 @@ function isValidEditorDraftSnapshot(snapshot: EditorDraftSnapshot): boolean { !snapshot || snapshot.schemaVersion !== 1 || typeof snapshot.text !== 'string' || + !validPluginMentions(snapshot.text, snapshot.pluginMentions) || !Number.isInteger(snapshot.cursor) || snapshot.cursor < 0 || snapshot.cursor > snapshot.text.length || @@ -698,7 +825,10 @@ function isValidEditorDraftSnapshot(snapshot: EditorDraftSnapshot): boolean { return false; } attachmentIds.add(element.id); - ranges.push({ start: attachmentElementStart(snapshot.text, element), end: element.end }); + ranges.push({ + start: attachmentElementStart(snapshot.text, element), + end: element.end, + }); return true; }); } @@ -728,7 +858,14 @@ function remapEditorDraftPastes( const id = Number(match[1]); const paste = draft.pastes.find((candidate) => candidate.id === id); if (!paste || match.index === undefined) return []; - return [{ start: match.index, end: match.index + match[0].length, paste, marker: match[0] }]; + return [ + { + start: match.index, + end: match.index + match[0].length, + paste, + marker: match[0], + }, + ]; }) .map((replacement, index) => { const id = startingCounter + index + 1; @@ -754,8 +891,16 @@ function remapEditorDraftPastes( schemaVersion: 1, text, cursor: shiftPosition(draft.cursor), - pastes: replacements.map(({ id, paste }) => ({ id, content: paste.content })), + pastes: replacements.map(({ id, paste }) => ({ + id, + content: paste.content, + })), pasteCounter: startingCounter + replacements.length, + pluginMentions: draft.pluginMentions?.map((mention) => ({ + ...mention, + start: shiftPosition(mention.start), + end: shiftPosition(mention.end), + })), attachmentPlaceholders: (draft.attachmentPlaceholders ?? []).map((element) => ({ ...element, start: shiftPosition(element.start), @@ -787,7 +932,11 @@ function attachmentElementEnd(text: string, element: EditorAttachmentElement): n function appendAttachmentElements( text: string, elements: ReadonlyMap, -): { text: string; cursor: number; elements: Map } { +): { + text: string; + cursor: number; + elements: Map; +} { let output = text; let cursor = output.length; const appended = new Map(); @@ -812,6 +961,29 @@ export function submittedEditorContent(draft: EditorDraftSnapshot): string { return expandDraftPastes(visibleText, draft.pastes).trim(); } +/** Serialize identity bindings into the existing durable user-text transport. */ +export function submittedEditorTransport(draft: EditorDraftSnapshot): string | undefined { + if (!draft.pluginMentions?.length) return undefined; + let text = draft.text; + const replacements = [ + ...draft.pluginMentions.map((mention) => ({ + start: mention.start, + end: mention.end, + text: encodePluginMentions(mention.label, [ + { ...mention, start: 0, end: mention.label.length }, + ]), + })), + ...(draft.attachmentPlaceholders ?? []).map((element) => ({ + start: attachmentElementStart(text, element), + end: attachmentElementEnd(text, element), + text: '', + })), + ].sort((a, b) => b.start - a.start); + for (const replacement of replacements) + text = text.slice(0, replacement.start) + replacement.text + text.slice(replacement.end); + return expandDraftPastes(text, draft.pastes).trim(); +} + function removeAttachmentElements( text: string, elements: readonly EditorAttachmentElement[], diff --git a/packages/tui/src/tui/widgets/editor/plugin-mentions.ts b/packages/tui/src/tui/widgets/editor/plugin-mentions.ts new file mode 100644 index 00000000..ca882832 --- /dev/null +++ b/packages/tui/src/tui/widgets/editor/plugin-mentions.ts @@ -0,0 +1,134 @@ +import { + parsePluginMentions, + serializePluginMention, + type PluginMention, +} from '@mavis/shared/plugin-mention'; + +export type EditorPluginMention = PluginMention; + +/** Decode durable history links into visible labels with exact identity bindings. */ +export function decodePluginMentions(text: string): { + text: string; + mentions: EditorPluginMention[]; +} { + const mentions: EditorPluginMention[] = []; + let offset = 0; + let previousEnd = 0; + let output = ''; + for (const mention of parsePluginMentions(text)) { + output += text.slice(previousEnd, mention.start) + mention.label; + const start = mention.start + offset; + mentions.push({ ...mention, start, end: start + mention.label.length }); + offset += mention.label.length - (mention.end - mention.start); + previousEnd = mention.end; + } + return { text: output + text.slice(previousEnd), mentions }; +} + +export function encodePluginMentions( + text: string, + mentions: readonly EditorPluginMention[], +): string { + let output = text; + for (const mention of [...mentions].sort((a, b) => b.start - a.start)) { + if (text.slice(mention.start, mention.end) !== mention.label) continue; + output = + output.slice(0, mention.start) + + serializePluginMention(mention.pluginId, mention.label) + + output.slice(mention.end); + } + return output; +} + +export function validPluginMentions( + text: string, + value: unknown, +): value is readonly EditorPluginMention[] | undefined { + if (value === undefined) return true; + if (!Array.isArray(value)) return false; + const ranges: Array<{ start: number; end: number }> = []; + return value.every((item) => { + if ( + !item || + typeof item !== 'object' || + typeof item.pluginId !== 'string' || + !item.pluginId || + item.pluginId.length > 512 || + /[\u0000-\u001f\u007f]/u.test(item.pluginId) || + typeof item.label !== 'string' || + !item.label.startsWith('@') || + item.label.length > 257 || + !Number.isInteger(item.start) || + !Number.isInteger(item.end) || + item.start < 0 || + item.end <= item.start || + item.end > text.length || + text.slice(item.start, item.end) !== item.label || + ranges.some((range) => item.start < range.end && item.end > range.start) + ) + return false; + ranges.push(item); + return true; + }); +} + +/** Edits inside a mention remove its binding; edits outside only move its range. */ +export function transformPluginMentions( + previous: string, + next: string, + mentions: readonly EditorPluginMention[], +): EditorPluginMention[] { + let start = 0; + while (start < previous.length && start < next.length && previous[start] === next[start]) start++; + let oldEnd = previous.length; + let newEnd = next.length; + while (oldEnd > start && newEnd > start && previous[oldEnd - 1] === next[newEnd - 1]) { + oldEnd--; + newEnd--; + } + return mentions.flatMap((mention) => { + if (mention.end <= start) return [{ ...mention }]; + if (mention.start < oldEnd) return []; + const delta = newEnd - oldEnd; + return [{ ...mention, start: mention.start + delta, end: mention.end + delta }]; + }); +} + +/** External edits can change several ranges at once; only relocate unambiguous labels. */ +export function transformExternalPluginMentions( + previous: string, + next: string, + mentions: readonly EditorPluginMention[], +): EditorPluginMention[] { + const positions = new Map(); + const occurrences = (text: string, label: string): number[] => { + const result: number[] = []; + for ( + let start = text.indexOf(label); + start >= 0; + start = text.indexOf(label, start + label.length) + ) { + result.push(start); + } + return result; + }; + return mentions + .flatMap((mention) => { + let matches = positions.get(mention.label); + if (!matches) { + matches = { + before: occurrences(previous, mention.label), + after: occurrences(next, mention.label), + }; + positions.set(mention.label, matches); + } + // Inserting/removing a duplicate visible label makes its source identity ambiguous. + if (matches.before.length !== matches.after.length) return []; + if (matches.before.length === 1) { + const start = matches.after[0]!; + return [{ ...mention, start, end: start + mention.label.length }]; + } + return transformPluginMentions(previous, next, [mention]); + }) + .sort((a, b) => a.start - b.start); +} diff --git a/packages/tui/test/unit/tui-app.test.ts b/packages/tui/test/unit/tui-app.test.ts index fac9a117..e319311d 100644 --- a/packages/tui/test/unit/tui-app.test.ts +++ b/packages/tui/test/unit/tui-app.test.ts @@ -1982,7 +1982,7 @@ describe("createTuiApp", () => { const starting = app.tui.render(80).join("\n"); expect(starting).toContain("⠋ Starting server..."); - expect(starting).not.toContain("Start · @ file · / autocomplete"); + expect(starting).not.toContain("Start · @ file or Plugin · / autocomplete"); expect(starting).not.toContain("Loading session"); releaseSessions?.(); @@ -1990,7 +1990,7 @@ describe("createTuiApp", () => { app.setStartupStatus(undefined); expect(app.tui.render(80).join("\n")).toContain( - "Start · @ file · / autocomplete", + "Start · @ file or Plugin · / autocomplete", ); } finally { releaseSessions?.(); diff --git a/packages/tui/test/unit/tui-chat-controller.test.ts b/packages/tui/test/unit/tui-chat-controller.test.ts index 922c4e5f..1d089a2b 100644 --- a/packages/tui/test/unit/tui-chat-controller.test.ts +++ b/packages/tui/test/unit/tui-chat-controller.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import type { SendMessageReq } from '@mavis/local-runtime-v2/cli-service'; +import type { CliSendMessageReq, SendMessageReq } from '@mavis/local-runtime-v2/cli-service'; import { TuiFailure } from '../../src/failure.js'; import { TuiChatController as ProductionTuiChatController, @@ -20,6 +20,50 @@ class TuiChatController extends ProductionTuiChatController { } describe('TuiChatController', () => { + it.each(['display', 'legacy transport'])( + 'reconciles plugin mentions using %s without duplicate user cells', + async (echo) => { + const content = '[@Codex 插件](plugin://codex%40official) 这个插件可以干什么'; + const displayContent = '@Codex 插件 这个插件可以干什么'; + let turn = 0; + const runtime = { + createSession: vi.fn(async () => ({ sessionId: 'plugin-session' })), + sendMessage: vi.fn(async function* ( + request: SendMessageReq, + ): AsyncGenerator { + expect(request).toMatchObject({ content, displayContent }); + const message = { + id: `message-${request.turnId}`, + turnId: request.turnId, + role: 'user' as const, + content: echo === 'display' ? request.displayContent : request.content, + }; + yield { type: 'message', message }; + yield { type: 'message', message }; + yield { type: 'done', turnId: request.turnId }; + }), + abortSession: vi.fn(async () => true), + }; + const transcript = new TranscriptStore(); + const controller = new ProductionTuiChatController({ + runtime: runtime as never, + transcript, + workspaceDir: '/workspace', + createTurnId: () => `plugin-turn-${++turn}`, + }); + await controller.submit(content, { displayContent }); + expect(transcript.snapshot().filter((cell) => cell.kind === 'user')).toMatchObject([ + { content: displayContent, sourceMessageId: 'message-plugin-turn-1' }, + ]); + // An intentional repeat in another turn must remain a separate message. + await controller.submit(content, { displayContent }); + expect(transcript.snapshot().filter((cell) => cell.kind === 'user')).toHaveLength(2); + const rendered = new TranscriptView(transcript).render(100).join('\n'); + expect(rendered).not.toContain('plugin://'); + expect(rendered).not.toContain('\x1b[4m'); + }, + ); + it('hides plain and namespaced AskUser protocol tools from the transcript', () => { expect(isQuestionnaireTool('ask_user')).toBe(true); expect(isQuestionnaireTool('functions.AskUser')).toBe(true); diff --git a/packages/tui/test/unit/tui-composer.test.ts b/packages/tui/test/unit/tui-composer.test.ts index 5c6da276..eb7d980a 100644 --- a/packages/tui/test/unit/tui-composer.test.ts +++ b/packages/tui/test/unit/tui-composer.test.ts @@ -312,7 +312,7 @@ describe("TuiComposer", () => { ); const firstHeader = stripAnsi(composer.render(120)[0] ?? ""); - expect(firstHeader).toContain("Start · @ file · / autocomplete"); + expect(firstHeader).toContain("Start · @ file or Plugin · / autocomplete"); expect(firstHeader).toContain( "Tip: /goal keeps multi-step work focused on a finish line", ); @@ -329,7 +329,7 @@ describe("TuiComposer", () => { { now: () => 30_000, tips: ordinaryTip ? [ordinaryTip] : [] }, ); const nextHeader = stripAnsi(nextComposer.render(120)[0] ?? ""); - expect(nextHeader).toContain("Start · @ file · / autocomplete"); + expect(nextHeader).toContain("Start · @ file or Plugin · / autocomplete"); expect(nextHeader).toContain( "Tip: Ctrl+U resumes your recent Codex session", ); @@ -459,7 +459,7 @@ describe("TuiComposer", () => { ], [ { mode: "message", surface: "welcome" }, - "Start · @ file · / autocomplete", + "Start · @ file or Plugin · / autocomplete", ], ] satisfies Array<[TuiComposerState, string]>)( "explains the %s submission mode before the user presses Enter", diff --git a/packages/tui/test/unit/tui-plugin-mentions.test.ts b/packages/tui/test/unit/tui-plugin-mentions.test.ts new file mode 100644 index 00000000..6ddf09f0 --- /dev/null +++ b/packages/tui/test/unit/tui-plugin-mentions.test.ts @@ -0,0 +1,677 @@ +import { ConversationApplication } from '../../../local-runtime-v2/src/application/conversation/conversation-application.js'; +import { DirectSendDeliveryService } from '../../../local-runtime-v2/src/application/conversation/direct-send-delivery.js'; +import { TuiUserProjection } from '../../src/tui/controller/projection/turn-user-projection.js'; +import { TranscriptStore } from '../../src/tui/transcript/store.js'; +import { visibleWidth } from '../../src/tui/engine/public.js'; +import { TuiInputFlow } from '../../src/tui/controller/interaction/input-flow.js'; +import { TuiExternalEditorFlow } from '../../src/tui/controller/interaction/external-editor-flow.js'; +import { TuiSessionMutationFlow } from '../../src/tui/controller/product/session-mutation-flow.js'; +import { UserMessageTurnDeliveryService } from '../../../local-runtime-v2/src/application/conversation/user-message-turn-delivery.js'; +import { UserMessageCommitService } from '../../../local-runtime-v2/src/service/session-system/messages/user-message-commit-service.js'; +import { + normalizeDisplayMessage, + decodeDisplayMessage, +} from '../../../local-runtime-v2/src/service/session-system/messages/repo/codec.js'; +import { toSessionMessageView } from '../../../local-runtime-v2/src/application/session/content-application.js'; +import { normalizeTuiMessage } from '../../src/runtime/stream-events.js'; +import { TuiHistorySearchPanel } from '../../src/tui/features/history/search-panel.js'; +import { TuiQueueFlow } from '../../src/tui/controller/run/queue-flow.js'; +import { TuiRunProjection } from '../../src/tui/state/run-projection.js'; +import { TuiCommandFlow } from '../../src/tui/controller/product/command-flow.js'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { serializePluginMention, parsePluginMentions } from '@mavis/shared/plugin-mention'; +import { createTuiAutocomplete } from '../../src/tui/controller/run/active-run-flow.js'; +import { + Editor, + submittedEditorTransport, + submittedEditorContent, +} from '../../src/tui/widgets/editor/editor.js'; +import { TuiDraftRecovery } from '../../src/tui/features/composer/draft-recovery.js'; +import { createTuiSubmissionSnapshot } from '../../src/tui/features/composer/submission.js'; +import { stripTerminalSequences } from '../../src/tui/engine/public.js'; + +const identity = (value: string) => value; +const makePlugin = (name = 'notes', marketplace: 'local' | 'official' = 'local') => ({ + pluginId: `${name}@${marketplace}`, + name, + displayName: 'My Notes', + marketplace, + installed: true, + enabled: true, + capabilities: { appCount: 0, mcpServerCount: 0, skillCount: 1 }, +}); +const editors: Editor[] = []; +afterEach(() => { + for (const editor of editors.splice(0)) editor.dispose(); +}); +function createEditor() { + const editor = new Editor( + { terminal: { rows: 24 }, requestRender: vi.fn() }, + { + borderColor: identity, + selectList: { + selectedPrefix: identity, + selectedText: identity, + description: identity, + scrollInfo: identity, + noMatch: identity, + }, + }, + ); + editors.push(editor); + editor.focused = true; + editor.setAutocompleteProvider( + createTuiAutocomplete([], [], '/workspace', { + listInstalledPlugins: async () => [makePlugin()], + listWorkspaceFileTree: async () => [], + searchWorkspaceFiles: async () => [], + }), + ); + return editor; +} +async function selectPlugin(editor: Editor) { + editor.handleInput('@'); + await vi.waitFor(() => + expect(stripTerminalSequences(editor.render(80).join('\n'))).toContain('local · notes'), + ); + editor.handleInput('\t'); + expect(editor.getText()).toBe('@My Notes '); + expect(editor.captureDraft().pluginMentions).toEqual([ + { pluginId: 'notes@local', label: '@My Notes', start: 0, end: 9 }, + ]); +} + +describe('Plugin mentions from Composer to durable text', () => { + it('carries the display label through Runtime direct-send admission while preserving execution identity', async () => { + const submit = vi.fn(async () => ({ accepted: false, reason: 'invalid-input' })); + const directSend = new DirectSendDeliveryService({ + turns: { submit }, + stream: { reserve: () => ({ discardIfEmpty: vi.fn() }) }, + } as never); + const application = new ConversationApplication({ directSend } as never); + const content = '[@Codex 插件](plugin://codex%40official) hello'; + const displayContent = '@Codex 插件 hello'; + await application.sendMessage({}, { id: 's1', content, displayContent }); + expect(submit).toHaveBeenCalledWith(expect.objectContaining({ + input: expect.objectContaining({ text: content }), + delivery: expect.objectContaining({ displayContent }), + })); + }); + + it('renders legacy plugin history as plain labels without losing editable transport', () => { + const transcript = new TranscriptStore(); + const message = { + role: 'user' as const, + content: '[@Codex 插件](plugin://codex%40official) hello', + }; + new TuiUserProjection(transcript).hydrate(message, 'message-1', 'turn-1', 1); + expect(transcript.snapshot()[0]?.content).toBe('@Codex 插件 hello'); + expect(message.content).toContain('plugin://'); + }); + + it('groups plugins and files, bounds wide descriptions, and keeps all matching plugins selectable', async () => { + const provider = createTuiAutocomplete([], [], '/workspace', { + listInstalledPlugins: async () => + Array.from({ length: 10 }, (_, index) => ({ + ...makePlugin(`notes-${index}`), + description: '读取、生成、重排、填写和处理文件。'.repeat(20), + })), + listWorkspaceFileTree: async () => [ + { name: 'notes.md', path: 'notes.md', type: 'file' as const }, + ], + }); + const suggestions = await provider.getSuggestions(['@'], 0, 1, { + signal: new AbortController().signal, + }); + expect(suggestions?.items).toHaveLength(11); + expect(suggestions?.items[0]).toMatchObject({ groupLabel: ' Plugins' }); + expect(suggestions?.items[10]).toMatchObject({ groupLabel: ' Files' }); + for (const item of suggestions!.items.slice(0, 10)) { + expect(visibleWidth(item.description!)).toBeLessThanOrEqual(72); + expect(item.description).toContain('…'); + } + const editor = createEditor(); + editor.setAutocompleteProvider(provider); + editor.handleInput('@'); + await vi.waitFor(() => expect(editor.render(180).join('\n')).toContain('Plugins')); + expect(editor.render(180).join('\n')).not.toContain('local · notes'); + for (const width of [40, 80, 180]) { + for (const line of editor.render(width)) + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + for (let index = 0; index < 10; index++) editor.handleInput('\x1b[B'); + expect(editor.render(80).join('\n')).toContain('Files'); + editor.handleInput('\t'); + expect(editor.getText()).toContain('notes.md'); + }); + + it('offers enabled plugins alongside files, refreshes state, and preserves file completion', async () => { + const listInstalledPlugins = vi.fn(async () => [ + makePlugin(), + { ...makePlugin('off'), enabled: false }, + ]); + const provider = createTuiAutocomplete([], [], '/workspace', { + listInstalledPlugins, + listWorkspaceFileTree: async () => [ + { name: 'notes.md', path: 'notes.md', type: 'file' as const }, + ], + }); + const request = { signal: new AbortController().signal }; + const result = await provider.getSuggestions(['@'], 0, 1, request); + expect(result?.items).toHaveLength(2); + expect(result?.items[0]).toMatchObject({ pluginId: 'notes@local' }); + expect(result?.items[1]?.value).toContain('notes.md'); + listInstalledPlugins.mockResolvedValue([{ ...makePlugin(), enabled: false }]); + expect((await provider.getSuggestions(['@'], 0, 1, request))?.items).toHaveLength(1); + expect(provider.applyCompletion(['see @ suffix'], 0, 5, result!.items[0]!, '@').lines).toEqual([ + 'see @My Notes suffix', + ]); + }); + + it('renders a label, submits stable identity, and preserves history through multiple recalls', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.handleInput('整理笔记'); + const submitted = vi.fn(); + editor.onSubmit = submitted; + editor.handleInput('\r'); + const [content, draft] = submitted.mock.calls[0]!; + expect(content).toBe('@My Notes 整理笔记'); + const transport = submittedEditorTransport(draft); + expect(transport).toBe('[@My Notes](plugin://notes%40local) 整理笔记'); + editor.addToHistory(content); + editor.addToHistory('another prompt'); + editor.handleInput('\x1b[A'); + expect(editor.getText()).toBe('another prompt'); + editor.handleInput('\x1b[A'); + expect(editor.getText()).toBe(content); + expect(submittedEditorTransport(editor.captureDraft())).toBe(transport); + editor.handleInput('\x1b[B'); + expect(editor.getText()).toBe('another prompt'); + expect(editor.captureDraft().pluginMentions).toEqual([]); + }); + + it('deletes a selected mention atomically and restores its exact identity with undo', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.handleInput('\x7f'); + expect(editor.getText()).toBe(''); + expect(editor.captureDraft().pluginMentions).toEqual([]); + editor.handleInput('\x1f'); + expect(editor.getText()).toBe('@My Notes '); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local)', + ); + }); + + it('restores the working draft identity after browsing history', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.addToHistory('older'); + editor.handleInput('\x01'); + editor.handleInput('\x1b[A'); + expect(editor.getText()).toBe('older'); + editor.handleInput('\x1b[B'); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local)', + ); + }); + + it('keeps identities distinct when history labels are identical', () => { + const editor = createEditor(); + editor.addToHistory(serializePluginMention('notes@local', 'My Notes')); + editor.addToHistory(serializePluginMention('notes@official', 'My Notes')); + editor.handleInput('\x1b[A'); + expect(editor.captureDraft().pluginMentions?.[0]?.pluginId).toBe('notes@official'); + editor.handleInput('\x1b[A'); + expect(editor.captureDraft().pluginMentions?.[0]?.pluginId).toBe('notes@local'); + }); + + it('preserves mentions with attachments, merged retries and persisted drafts', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.syncAttachmentPlaceholders([{ id: '/image.png', label: '[Image #1]' }]); + editor.handleInput('summarize'); + const snapshot = editor.captureDraft(); + expect(submittedEditorTransport(snapshot)).toBe( + '[@My Notes](plugin://notes%40local) summarize', + ); + const submission = createTuiSubmissionSnapshot({ + submissionId: 's1', + editor: snapshot, + content: submittedEditorContent(snapshot), + resources: { attachments: [] }, + }); + const directory = await mkdtemp(join(tmpdir(), 'plugin-mention-test-')); + try { + const store = new TuiDraftRecovery({ dataDir: directory, workspaceDir: '/workspace' }); + const plain = { + ...snapshot, + text: '@My Notes summarize', + cursor: 19, + attachmentPlaceholders: [], + }; + await store.flush({ + editor: plain, + attachments: [], + retrySubmissions: [ + { + retryId: 'retry:r1', + failedReason: 'offline', + snapshot: { ...submission, editor: plain }, + }, + ], + }); + const loaded = await new TuiDraftRecovery({ + dataDir: directory, + workspaceDir: '/workspace', + }).load(); + expect(loaded?.editor.pluginMentions).toEqual(snapshot.pluginMentions); + expect(loaded?.retrySubmissions?.[0]?.snapshot.editor.pluginMentions).toEqual( + snapshot.pluginMentions, + ); + editor.setText('new draft'); + expect(editor.restoreSubmittedDraft(loaded!.editor)).toBe(true); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local) summarize\nnew draft', + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('shows human-readable history search and restores the canonical identity', () => { + const entry = serializePluginMention('notes@official', 'My Notes'); + const editor = createEditor(); + const panel = new TuiHistorySearchPanel({ + entries: [entry], + initialQuery: 'My Notes', + onSelect: (text) => editor.setText(text), + onCancel: vi.fn(), + requestRender: vi.fn(), + }); + const rendered = stripTerminalSequences(panel.render(100).join('\n')); + expect(rendered).toContain('@My Notes'); + expect(rendered).not.toContain('plugin://'); + panel.handleInput('\r'); + expect(editor.captureDraft().pluginMentions?.[0]?.pluginId).toBe('notes@official'); + }); + + it('rejects corrupt restored ranges and malformed IDs without binding another plugin', () => { + const editor = createEditor(); + expect( + editor.restoreDraft({ + schemaVersion: 1, + text: '@Notes', + cursor: 6, + pastes: [], + pasteCounter: 0, + pluginMentions: [{ pluginId: 'notes@local', label: '@Wrong', start: 0, end: 6 }], + }), + ).toBe(false); + expect(parsePluginMentions('[@Notes](plugin://%ZZ)')).toEqual([]); + }); + it('submits bound text through the real command flow and restores the binding on failure', async () => { + const editor = createEditor(); + await selectPlugin(editor); + editor.handleInput('summarize'); + const draft = editor.captureDraft(); + const content = submittedEditorContent(draft); + const submit = vi.fn( + async ( + _text: string, + options: { + onSessionResolved?: (id: string) => void; + onRuntimeAccepted?: (id: string) => void; + }, + ) => { + options.onSessionResolved?.('session-a'); + options.onRuntimeAccepted?.('session-a'); + return 'succeeded' as const; + }, + ); + const flow = new TuiCommandFlow({ + workspaceDir: '/workspace', + controller: { + snapshot: () => ({ status: 'idle', sessions: [], session: { sessionId: 'session-a' } }), + submit, + refreshSessionMetadata: async () => undefined, + } as never, + activeRunFlow: {} as never, + featureFlow: { + skillCommands: () => [], + waitForWelcomeModelSelection: async () => undefined, + applyPendingModelSelection: async () => undefined, + } as never, + feedbackFlow: {} as never, + updateFlow: {} as never, + interactionFlow: { handleCommand: async () => false, hasPending: () => false } as never, + sessionFlow: {} as never, + queueFlow: {} as never, + composerDraft: { + hasContent: () => false, + capture: () => ({ attachments: [] }), + reserveSubmission: vi.fn(), + restoreSubmission: vi.fn(), + completeSubmission: async () => undefined, + } as never, + workspaceRoots: { additionalDirectories: () => [] } as never, + runProjection: new TuiRunProjection(), + editor, + surface: {} as never, + surfaceHost: { setChatFocus: vi.fn() } as never, + queueEnabled: true, + liveRunId: () => undefined, + runtimeStopping: () => false, + abortLiveTurn: async () => false, + leaveUi: async () => undefined, + whenReady: async () => undefined, + append: vi.fn(), + setHint: vi.fn(), + onChanged: vi.fn(), + }); + const seed = flow.captureSubmissionSeed(draft); + editor.setText(''); + await expect(flow.submit(content, seed)).resolves.toBe('consumed'); + expect(submit).toHaveBeenCalledWith( + '[@My Notes](plugin://notes%40local) summarize', + expect.objectContaining({ displayContent: content }), + ); + flow.restoreRecoverableSubmission({ + submissionId: 'recovered', + sessionId: 'session-a', + editor: draft, + content, + attachments: [], + createdAtMs: 1, + transportContent: + 'context\n\n[@My Notes](plugin://notes%40local) summarize', + }); + editor.restoreDraft(draft); + editor.handleInput(' tomorrow'); + const retrySeed = flow.captureSubmissionSeed(); + await flow.submit('@My Notes summarize tomorrow', retrySeed); + expect(submit).toHaveBeenLastCalledWith( + 'context\n\n[@My Notes](plugin://notes%40local) summarize tomorrow', + expect.objectContaining({ displayContent: '@My Notes summarize tomorrow' }), + ); + editor.setText(''); + await flow.restoreFailedSeed(content, seed); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local) summarize', + ); + }); + + it.each(['', 'synthetic context\n\n'])( + 'reconstructs queued bindings and context after reload: %s', + async (context) => { + let item = { + itemId: 'q1', + sessionId: 's1', + status: 'queued', + source: 'user', + content: `${context}[@My Notes](plugin://notes%40local) summarize`, + attachments: [], + }; + const projection = new TuiRunProjection(); + const updateQueuedMessageContent = vi.fn( + async (_session: string, _item: string, content: string) => { + item = { ...item, content }; + return item; + }, + ); + let removed = false; + const queue = new TuiQueueFlow({ + runtime: { + getQueueSnapshot: async () => ({ + items: removed ? [] : [item], + paused: true, + pendingCount: removed ? 0 : 1, + }), + deleteQueuedMessage: async () => { + removed = true; + return true; + }, + updateQueuedMessageContent, + } as never, + controller: { snapshot: () => ({ session: { sessionId: 's1' } }) } as never, + composerDraft: { restoreQueuedSubmission: vi.fn(), releaseQueueItem: vi.fn() } as never, + runProjection: projection, + transcript: { get: vi.fn(), remove: vi.fn(), upsert: vi.fn() } as never, + followUp: { setQueueSummary: vi.fn(), setItems: vi.fn() } as never, + surface: {} as never, + enabled: true, + setHint: vi.fn(), + onChanged: vi.fn(), + requestRender: vi.fn(), + }); + await queue.refresh('s1'); + expect(projection.snapshot().queuedItems[0]?.content).toBe('@My Notes summarize'); + await queue.updateContent('q1', '@My Notes summarize tomorrow'); + expect(updateQueuedMessageContent).toHaveBeenCalledWith( + 's1', + 'q1', + `${context}[@My Notes](plugin://notes%40local) summarize tomorrow`, + ); + const restored = await queue.restoreLatest(); + expect(restored?.content).toBe('@My Notes summarize tomorrow'); + expect(submittedEditorTransport(restored!.editor)).toBe( + '[@My Notes](plugin://notes%40local) summarize tomorrow', + ); + }, + ); +}); + +describe('Plugin mention review regressions', () => { + it('clears and restores the actual editor binding with Ctrl+C and Ctrl+-', () => { + const editor = createEditor(); + editor.setText('[@My Notes](plugin://notes%40local) summarize'); + const flow = new TuiInputFlow({ + editor, + tui: { requestRender: vi.fn() }, + interaction: { isActive: () => false }, + liveRunId: () => undefined, + hasWaitingMessage: () => false, + featureFlow: { isFeatureScreenActive: () => false }, + composerDraft: { + abortClipboardRead: () => false, + stashForClear: vi.fn(), + hasContent: () => false, + restoreClearedDraft: vi.fn(), + }, + setHint: vi.fn(), + onChanged: vi.fn(), + workspaceDir: '/workspace', + } as never); + flow.handle('\x03'); + expect(editor.getText()).toBe(''); + expect(editor.captureDraft().pluginMentions).toEqual([]); + flow.handle('\x1f'); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local) summarize', + ); + }); + + it.each([false, true])( + 'keeps external edits and undo bound, including expanded paste: %s', + async (withPaste) => { + const editor = createEditor(); + editor.setText('[@My Notes](plugin://notes%40local) summarize'); + if (withPaste) { + const draft = editor.captureDraft(); + const prefix = '[paste #1] '; + editor.restoreDraft({ + ...draft, + text: prefix + draft.text, + cursor: prefix.length + draft.text.length, + pastes: [{ id: 1, content: 'Expanded text before plugin' }], + pasteCounter: 1, + pluginMentions: draft.pluginMentions!.map((mention) => ({ + ...mention, + start: mention.start + prefix.length, + end: mention.end + prefix.length, + })), + }); + } + const before = editor.captureDraft(); + const transport = submittedEditorTransport(before); + let rename = false; + const flow = new TuiExternalEditorFlow({ + editor, + tui: { start: vi.fn(), stop: vi.fn(), requestRender: vi.fn() }, + workspaceDir: '/workspace', + configuredCommand: 'synthetic-editor', + editDraft: async ({ draft }) => + rename ? draft.replace('@My Notes', '@Other Notes') : draft + ' tomorrow', + isAppStopped: () => false, + append: vi.fn(), + setHint: vi.fn(), + onChanged: vi.fn(), + }); + await flow.open(); + expect(submittedEditorTransport(editor.captureDraft())).toBe(transport + ' tomorrow'); + editor.handleInput('\x1f'); + expect(editor.captureDraft()).toEqual(before); + rename = true; + await flow.open(); + expect(editor.captureDraft().pluginMentions).toEqual([]); + expect(submittedEditorTransport(editor.captureDraft())).toBeUndefined(); + }, + ); + + it('retains an unchanged plugin across multiple external edits without guessing duplicate identities', async () => { + const editor = createEditor(); + editor.setText('[@My Notes](plugin://notes%40local) summarize'); + const flow = new TuiExternalEditorFlow({ + editor, + tui: { start: vi.fn(), stop: vi.fn(), requestRender: vi.fn() }, + workspaceDir: '/workspace', + configuredCommand: 'synthetic-editor', + editDraft: async ({ draft }) => 'Please ' + draft + ' tomorrow', + isAppStopped: () => false, + append: vi.fn(), + setHint: vi.fn(), + onChanged: vi.fn(), + }); + await flow.open(); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + 'Please [@My Notes](plugin://notes%40local) summarize tomorrow', + ); + editor.setText( + '[@My Notes](plugin://notes%40local) and [@My Notes](plugin://notes%40official)', + ); + editor.replaceTextUndoable('@My Notes and @My Notes tomorrow'); + expect(submittedEditorTransport(editor.captureDraft())).toBe( + '[@My Notes](plugin://notes%40local) and [@My Notes](plugin://notes%40official) tomorrow', + ); + editor.replaceTextUndoable('@My Notes tomorrow'); + expect(editor.captureDraft().pluginMentions).toEqual([]); + }); + + it.each(['ordinary', 'batch', 'steering'] as const)( + 'preserves %s display-message identity through storage, projection and /edit', + async (mode) => { + const canonical = + 'synthetic context\n\n[@My Notes](plugin://notes%40local) summarize'; + const display = '@My Notes summarize'; + let stored: ReturnType | undefined; + const commits = new UserMessageCommitService({ + makeMessageId: () => 'u1', + messages: { + commitUserMessage: async (input) => { + const row = normalizeDisplayMessage(input.message, { + turnId: input.turnId, + nowMs: () => 1, + }); + stored = decodeDisplayMessage({ + sessionId: input.sessionId, + messageId: row.msgId, + dataJson: row.dataJson, + source: null, + sourceContextJson: null, + } as never); + return { message: stored, created: true, firstUserMessageForSession: true }; + }, + }, + }); + const delivery = new UserMessageTurnDeliveryService({ + messages: commits, + stream: { write: vi.fn() }, + queryCollapse: { + resolveQueryKey: async () => 'q1', + start: async () => { + throw new Error('Synthetic unavailable sidecar'); + }, + }, + }); + const message = { content: canonical, attachments: [], displayContent: display }; + if (mode === 'steering') { + await delivery.consumeSteering({ + sessionId: 's1', + turnId: 't1', + message: { + producerId: 'cli', + idempotencyKey: 'fixture', + provenance: { source: 'cli' }, + message: { text: canonical, attachments: [] }, + delivery: { displayContent: display }, + }, + } as never); + } else { + await delivery.deliver({ + sessionId: 's1', + input: { text: canonical, attachments: [] }, + provenance: { source: 'cli' }, + displayContent: display, + requestedTurnId: 't1', + submit: async () => ({ accepted: true, turnId: 't1' }), + ...(mode === 'batch' + ? { + immediateSendBatch: { + members: [ + { message, messageKey: 'batch-1', createdAt: 1, provenance: { source: 'cli' } }, + ], + }, + } + : {}), + } as never); + } + expect(stored?.msg_content).toBe(display); + expect(stored?.editContent).toBe(canonical); + const reloaded = normalizeTuiMessage(toSessionMessageView(stored!)); + expect(reloaded.content).toBe(display); + expect(reloaded.editContent).toBe(canonical); + const editor = createEditor(); + const editSessionMessage = vi.fn(async () => ({})); + const flow = new TuiSessionMutationFlow({ + editor, + controller: { + snapshot: () => ({ session: { sessionId: 's1' } }), + getTerminalDurationId: () => undefined, + dismissTerminalDuration: vi.fn(), + }, + runtime: { + listSessionInputSummaries: async () => [{ userMessageId: 'u1', timestamp: 1 }], + listMessagePage: async () => ({ messages: [reloaded], hasMore: false }), + editSessionMessage, + }, + surfaceHost: { setChatFocus: vi.fn() }, + setHint: vi.fn(), + append: vi.fn(), + onChanged: vi.fn(), + hasLiveRun: () => false, + } as never); + flow.startEdit(); + await vi.waitFor(() => expect(flow.isEditing()).toBe(true)); + expect(editor.getText()).toBe(display); + editor.handleInput(' tomorrow'); + await flow.submitEdit(editor.getText(), [], editor.captureDraft()); + expect(editSessionMessage).toHaveBeenCalledWith( + expect.objectContaining({ content: canonical + ' tomorrow' }), + ); + }, + ); +}); diff --git a/packages/tui/test/unit/tui/theme/custom-themes.test.ts b/packages/tui/test/unit/tui/theme/custom-themes.test.ts index 23020c02..9b48b725 100644 --- a/packages/tui/test/unit/tui/theme/custom-themes.test.ts +++ b/packages/tui/test/unit/tui/theme/custom-themes.test.ts @@ -1,10 +1,12 @@ +import { watch as watchFs, writeFileSync } from 'node:fs'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { customThemesDirectory, loadCustomThemes, + watchCustomThemes, } from '../../../../src/tui/theme/custom-themes.js'; import { TuiThemeRegistry } from '../../../../src/tui/theme/registry.js'; import type { TuiThemeDefinition } from '../../../../src/tui/theme/contracts.js'; @@ -14,9 +16,15 @@ import { MINIMAX_CODE_LIGHT_THEME, } from '../../../../src/tui/theme/palettes.js'; +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, watch: vi.fn(actual.watch) }; +}); + const temporaryDirectories: string[] = []; afterEach(async () => { + vi.useRealTimers(); await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), ); @@ -42,6 +50,55 @@ function onlyTheme(themes: readonly TuiThemeDefinition[]): TuiThemeDefinition { } describe('custom TUI theme files', () => { + it('reconciles edits missed during watcher startup and continues to reload later events', async () => { + const dataDir = await themesDataDir({ + 'mine.json': { name: 'mine', appearance: 'dark', colors: { brand: '#112233' } }, + }); + const file = join(customThemesDirectory(dataDir), 'mine.json'); + expect(onlyTheme(loadCustomThemes(dataDir).themes).dark.colors.brand).toBe('#112233'); + const close = vi.fn(); + vi.mocked(watchFs).mockReturnValueOnce({ on: vi.fn(), close } as never); + vi.useFakeTimers(); + const onChange = vi.fn(); + const stop = watchCustomThemes(dataDir, onChange); + try { + // Model macOS dropping the first edit before native watching is ready. + writeFileSync( + file, + JSON.stringify({ name: 'mine', appearance: 'dark', colors: { brand: '#AABBCC' } }), + ); + await vi.advanceTimersByTimeAsync(150); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onlyTheme(onChange.mock.calls[0]![0].themes).dark.colors.brand).toBe('#AABBCC'); + + writeFileSync( + file, + JSON.stringify({ name: 'mine', appearance: 'dark', colors: { brand: '#DDEEFF' } }), + ); + const fire = vi.mocked(watchFs).mock.calls.at(-1)![2]!; + fire('change', 'mine.json'); + await vi.advanceTimersByTimeAsync(150); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onlyTheme(onChange.mock.calls[1]![0].themes).dark.colors.brand).toBe('#DDEEFF'); + } finally { + stop(); + } + expect(close).toHaveBeenCalledOnce(); + }); + + it('cancels the startup reconciliation when disposed', async () => { + const dataDir = await themesDataDir({}); + const close = vi.fn(); + vi.mocked(watchFs).mockReturnValueOnce({ on: vi.fn(), close } as never); + vi.useFakeTimers(); + const onChange = vi.fn(); + const stop = watchCustomThemes(dataDir, onChange); + stop(); + await vi.advanceTimersByTimeAsync(1000); + expect(onChange).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledOnce(); + }); + it('returns no themes when the directory is absent', async () => { const dataDir = await mkdtemp(join(tmpdir(), 'mcode-tui-themes-empty-')); temporaryDirectories.push(dataDir); diff --git a/packages/tui/test/unit/tui/theme/runtime.test.ts b/packages/tui/test/unit/tui/theme/runtime.test.ts index d0da7544..ab9e80a1 100644 --- a/packages/tui/test/unit/tui/theme/runtime.test.ts +++ b/packages/tui/test/unit/tui/theme/runtime.test.ts @@ -568,6 +568,7 @@ describe('TuiThemeController theme selection', () => { const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const dataDir = await mkdtemp(join(tmpdir(), 'mcode-theme-reload-')); + let controller: TuiThemeController | undefined; try { const themesDir = join(dataDir, 'tui', 'themes'); await mkdir(themesDir, { recursive: true }); @@ -577,7 +578,7 @@ describe('TuiThemeController theme selection', () => { await write('#112233'); const ui = new ThemeUi(); - const controller = new TuiThemeController({ + controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' }, @@ -594,8 +595,8 @@ describe('TuiThemeController theme selection', () => { expect(controller.selectedThemeId()).toBe('mine'); expect(onChange).toHaveBeenCalled(); - controller.dispose(); } finally { + controller?.dispose(); await rm(dataDir, { recursive: true, force: true }); restoreTheme(); } @@ -606,6 +607,7 @@ describe('TuiThemeController theme selection', () => { const { tmpdir } = await import('node:os'); const { join } = await import('node:path'); const dataDir = await mkdtemp(join(tmpdir(), 'mcode-theme-noop-')); + let controller: TuiThemeController | undefined; try { const themesDir = join(dataDir, 'tui', 'themes'); await mkdir(themesDir, { recursive: true }); @@ -619,7 +621,7 @@ describe('TuiThemeController theme selection', () => { const ui = new ThemeUi(); const onThemesChanged = vi.fn(); - const controller = new TuiThemeController({ + controller = new TuiThemeController({ ui, colorLevel: 3, env: { COLORFGBG: '15;0' }, @@ -629,8 +631,11 @@ describe('TuiThemeController theme selection', () => { controller.setTheme('mine'); const onChange = vi.fn(); controller.onChange(onChange); - // The constructor load already fired once; only a watcher-driven reload - // may satisfy the wait below. + // Let the constructor load and startup reconciliation finish before the + // no-op edit, so this assertion still requires a native watcher event. + await vi.waitFor(() => expect(onThemesChanged.mock.calls.length).toBeGreaterThanOrEqual(2), { + timeout: 4000, + }); onThemesChanged.mockClear(); await writeFile(file, body); @@ -640,8 +645,8 @@ describe('TuiThemeController theme selection', () => { expect(tuiColors.brand).toBe('#112233'); expect(onChange).not.toHaveBeenCalled(); - controller.dispose(); } finally { + controller?.dispose(); await rm(dataDir, { recursive: true, force: true }); restoreTheme(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8014dab7..00246441 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -335,6 +335,9 @@ importers: packages/agent-modules/system-reminder: devDependencies: + '@mavis/shared': + specifier: workspace:^ + version: link:../../shared '@mavis/config': specifier: workspace:^ version: link:../../config diff --git a/release/public-source.json b/release/public-source.json index d795f506..306681e2 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -1278,6 +1278,7 @@ "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-execution-preparation.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-input-preparation.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts", + "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-hooks.ts", "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-tool-catalog-source.ts", @@ -2763,6 +2764,7 @@ "packages/shared/src/opencode-go-headers.ts", "packages/shared/src/openrouter-attribution.ts", "packages/shared/src/outbound-media.ts", + "packages/shared/src/plugin-mention.ts", "packages/shared/src/plugin-skill-name.ts", "packages/shared/src/product-build-identity.ts", "packages/shared/src/product-time.ts", @@ -2951,6 +2953,7 @@ "packages/tui/src/tui/commands/bash-input.ts", "packages/tui/src/tui/commands/catalog.ts", "packages/tui/src/tui/commands/input-intent.ts", + "packages/tui/src/tui/commands/plugin-autocomplete.ts", "packages/tui/src/tui/commands/side-session.ts", "packages/tui/src/tui/controller/chat-controller-failure.ts", "packages/tui/src/tui/controller/chat-controller-support.ts", @@ -3202,6 +3205,7 @@ "packages/tui/src/tui/widgets/autocomplete.ts", "packages/tui/src/tui/widgets/editor/editor.ts", "packages/tui/src/tui/widgets/editor/paste.ts", + "packages/tui/src/tui/widgets/editor/plugin-mentions.ts", "packages/tui/src/tui/widgets/input.ts", "packages/tui/src/tui/widgets/panel-frame.ts", "packages/tui/src/tui/widgets/select-list.ts", @@ -3271,6 +3275,7 @@ "packages/tui/test/unit/tui-keybindings.test.ts", "packages/tui/test/unit/tui-model-picker.test.ts", "packages/tui/test/unit/tui-plugin-manager.test.ts", + "packages/tui/test/unit/tui-plugin-mentions.test.ts", "packages/tui/test/unit/tui-provider-editor.test.ts", "packages/tui/test/unit/tui-provider-manager.test.ts", "packages/tui/test/unit/tui-provider-onboarding.test.ts", diff --git a/test/vitest-suites.json b/test/vitest-suites.json index 40ae8d55..45b5daa4 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -173,7 +173,9 @@ "packages/tui/test/unit/tui/features/settings/theme-picker.test.ts", "packages/tui/test/unit/tui/theme/custom-themes.test.ts", "packages/tui/test/unit/tui/theme/palettes.test.ts", - "packages/tui/test/unit/tui/theme/runtime.test.ts" + "packages/tui/test/unit/tui/theme/runtime.test.ts", + "packages/tui/test/unit/tui-plugin-mentions.test.ts", + "packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts" ], "status-contract": [ "packages/tui/test/unit/tui-build-mode-contract.test.ts" diff --git a/tsconfig.standalone.json b/tsconfig.standalone.json index 1ad29d2b..df4c912a 100644 --- a/tsconfig.standalone.json +++ b/tsconfig.standalone.json @@ -328,6 +328,9 @@ "@mavis/shared/navigation-text": [ "./packages/shared/src/navigation-text.ts" ], + "@mavis/shared/plugin-mention": [ + "./packages/shared/src/plugin-mention.ts" + ], "@mavis/shared/product-build-identity": [ "./packages/shared/src/product-build-identity.ts" ],