From b537e0f3f33eaf9b4f132450cf30aa9c9859fa7c Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 23 Sep 2026 15:42:26 +0800 Subject: [PATCH 1/6] feat(tui): select plugins with durable identity mentions --- docs/tui-capabilities.md | 19 + .../system-reminder/package.json | 3 +- .../system-reminder/src/plugin-reference.ts | 72 +++- .../local-turn-plugin-capabilities.test.ts | 169 ++++++++ .../local-turn-plugin-capabilities.ts | 105 +++-- packages/shared/package.json | 4 + packages/shared/src/plugin-mention.ts | 40 ++ .../tui/src/runtime/adapters/plugin-access.ts | 3 +- .../src/tui/commands/plugin-autocomplete.ts | 101 +++++ .../tui/controller/interaction/input-flow.ts | 1 + .../tui/controller/product/command-flow.ts | 19 +- .../product/session-mutation-flow.ts | 4 +- .../src/tui/controller/run/active-run-flow.ts | 38 +- .../tui/src/tui/controller/run/queue-flow.ts | 23 +- .../tui/src/tui/engine/LOCAL_CHANGES.json | 6 +- packages/tui/src/tui/engine/LOCAL_CHANGES.md | 7 + .../tui/src/tui/engine/components/editor.ts | 11 +- .../tui/features/composer/draft-recovery.ts | 17 +- .../src/tui/features/composer/submission.ts | 5 +- .../src/tui/features/history/search-panel.ts | 7 +- packages/tui/src/tui/shell/composer.ts | 2 +- packages/tui/src/tui/widgets/editor/editor.ts | 204 ++++++++- .../src/tui/widgets/editor/plugin-mentions.ts | 95 +++++ .../tui/test/unit/tui-plugin-mentions.test.ts | 387 ++++++++++++++++++ pnpm-lock.yaml | 3 + release/public-source.json | 5 + test/vitest-suites.json | 4 +- tsconfig.standalone.json | 3 + 28 files changed, 1256 insertions(+), 101 deletions(-) create mode 100644 packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-plugin-capabilities.test.ts create mode 100644 packages/shared/src/plugin-mention.ts create mode 100644 packages/tui/src/tui/commands/plugin-autocomplete.ts create mode 100644 packages/tui/src/tui/widgets/editor/plugin-mentions.ts create mode 100644 packages/tui/test/unit/tui-plugin-mentions.test.ts diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index a67fb9f2..eb800300 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -132,3 +132,22 @@ 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, and saved drafts. + +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/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/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/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/tui/commands/plugin-autocomplete.ts b/packages/tui/src/tui/commands/plugin-autocomplete.ts new file mode 100644 index 00000000..59c00fcb --- /dev/null +++ b/packages/tui/src/tui/commands/plugin-autocomplete.ts @@ -0,0 +1,101 @@ +import type { McodePluginRuntimeAccess } from '../../plugin/contract.js'; +import type { AutocompleteItem, AutocompleteProvider } from '../widgets/autocomplete.js'; +import { sanitizeTerminalText } from '../rendering/terminal-text.js'; + +export interface PluginAutocompleteItem extends AutocompleteItem { + readonly pluginId: 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), + ) + .slice(0, 8) + .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, + description: sanitizeTerminalText( + `Plugin · ${plugin.marketplace} · ${plugin.description ?? plugin.name}`, + ), + }; + }) + : []; + const combined = [...items, ...(fileSuggestions?.items ?? [])]; + 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/interaction/input-flow.ts b/packages/tui/src/tui/controller/interaction/input-flow.ts index 75509bb3..cd379a0e 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) => 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..df33fcf3 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,9 @@ 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?.content, boundContent) ?? boundContent; if (invocation.phase === 'resubmit') { this.invocations.set(invocation.sequence, { ...invocation, phase: 'resubmitting' }); this.options.setHint(sessionMutationText('sessionMutation.hint.editSubmitting')); 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/widgets/editor/editor.ts b/packages/tui/src/tui/widgets/editor/editor.ts index f81e3eff..55237c0b 100644 --- a/packages/tui/src/tui/widgets/editor/editor.ts +++ b/packages/tui/src/tui/widgets/editor/editor.ts @@ -1,3 +1,10 @@ +import { + decodePluginMentions, + encodePluginMentions, + transformPluginMentions, + validPluginMentions, + type EditorPluginMention, +} from './plugin-mentions.js'; import type { AutocompleteProvider, AutocompleteSuggestions } from '../autocomplete.js'; import { decodePrintableKey, @@ -20,6 +27,7 @@ export type { EditorTheme }; export interface EditorDraftSnapshot extends EditorStateSnapshot { readonly attachmentPlaceholders?: readonly EditorAttachmentElement[]; + readonly pluginMentions?: readonly EditorPluginMention[]; } export interface EditorAttachmentPlaceholder { @@ -46,6 +54,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 +83,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 +155,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 +189,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 +199,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 +213,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 +238,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 +307,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 +322,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 +342,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 +416,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,9 +437,18 @@ 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); @@ -407,6 +494,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 +502,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 +547,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 +579,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 +640,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 +656,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 +670,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 +738,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 +770,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 +817,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 +850,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 +883,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 +924,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 +953,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..f734a5ff --- /dev/null +++ b/packages/tui/src/tui/widgets/editor/plugin-mentions.ts @@ -0,0 +1,95 @@ +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 }]; + }); +} 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..7ff29439 --- /dev/null +++ b/packages/tui/test/unit/tui-plugin-mentions.test.ts @@ -0,0 +1,387 @@ +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('Plugin · local'), + ); + 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('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', + ); + }, + ); +}); 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" ], From f1d56716d57b3a3390ca742dd246254c0a53daf7 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 23 Sep 2026 15:44:37 +0800 Subject: [PATCH 2/6] test(tui): update plugin mention discovery hints --- packages/tui/test/unit/tui-app.test.ts | 4 ++-- packages/tui/test/unit/tui-composer.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) 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-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", From ece9086bd60900f6992d7f718bc62b1ebb7b01c4 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 23 Sep 2026 17:03:06 +0800 Subject: [PATCH 3/6] fix(tui): preserve plugin bindings across draft and message edits --- docs/tui-capabilities.md | 5 +- .../user-message-turn-delivery.ts | 8 + .../session/content-application.ts | 1 + .../session-system/messages/repo/contract.ts | 2 + .../messages/user-message-commit-service.ts | 3 + packages/protocol/src/local.ts | 2 + packages/tui/src/runtime/stream-events.ts | 2 + .../tui/controller/interaction/input-flow.ts | 1 + .../product/session-mutation-flow.ts | 9 +- packages/tui/src/tui/widgets/editor/editor.ts | 9 +- .../tui/test/unit/tui-plugin-mentions.test.ts | 193 ++++++++++++++++++ 11 files changed, 231 insertions(+), 4 deletions(-) diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index eb800300..cca2d0ca 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -139,7 +139,10 @@ 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, and saved drafts. +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. 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 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/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/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/controller/interaction/input-flow.ts b/packages/tui/src/tui/controller/interaction/input-flow.ts index cd379a0e..612196e7 100644 --- a/packages/tui/src/tui/controller/interaction/input-flow.ts +++ b/packages/tui/src/tui/controller/interaction/input-flow.ts @@ -506,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/session-mutation-flow.ts b/packages/tui/src/tui/controller/product/session-mutation-flow.ts index df33fcf3..9ffc66d3 100644 --- a/packages/tui/src/tui/controller/product/session-mutation-flow.ts +++ b/packages/tui/src/tui/controller/product/session-mutation-flow.ts @@ -418,7 +418,10 @@ export class TuiSessionMutationFlow { } const boundContent = draft ? submittedEditorTransport(draft) ?? content : content; const transportContent = - rebuildSessionMutationTransport(invocation.targetMessage?.content, boundContent) ?? boundContent; + 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')); @@ -660,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/widgets/editor/editor.ts b/packages/tui/src/tui/widgets/editor/editor.ts index 55237c0b..04835f32 100644 --- a/packages/tui/src/tui/widgets/editor/editor.ts +++ b/packages/tui/src/tui/widgets/editor/editor.ts @@ -455,7 +455,14 @@ export class Editor implements Component, Focusable { } 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 = transformPluginMentions(previous.text, normalized, previous.mentions); + this.setText(encodePluginMentions(normalized, mentions)); } invalidate(): void { diff --git a/packages/tui/test/unit/tui-plugin-mentions.test.ts b/packages/tui/test/unit/tui-plugin-mentions.test.ts index 7ff29439..77525b41 100644 --- a/packages/tui/test/unit/tui-plugin-mentions.test.ts +++ b/packages/tui/test/unit/tui-plugin-mentions.test.ts @@ -1,3 +1,14 @@ +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'; @@ -385,3 +396,185 @@ describe('Plugin mentions from Composer to durable text', () => { }, ); }); + +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.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' }), + ); + }, + ); +}); From 15d9f914cdb07599465f155586f39fdcdaf9d439 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 23 Sep 2026 17:07:56 +0800 Subject: [PATCH 4/6] fix(tui): retain unique plugin labels across multiple external edits --- docs/tui-capabilities.md | 3 +- packages/tui/src/tui/widgets/editor/editor.ts | 3 +- .../src/tui/widgets/editor/plugin-mentions.ts | 39 +++++++++++++++++++ .../tui/test/unit/tui-plugin-mentions.test.ts | 29 ++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index cca2d0ca..2306cb99 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -141,7 +141,8 @@ selected independently. Choose a plugin with Tab or Enter, then describe the tas 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. Displayed messages remain readable; Runtime retains +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, diff --git a/packages/tui/src/tui/widgets/editor/editor.ts b/packages/tui/src/tui/widgets/editor/editor.ts index 04835f32..e7ee277f 100644 --- a/packages/tui/src/tui/widgets/editor/editor.ts +++ b/packages/tui/src/tui/widgets/editor/editor.ts @@ -2,6 +2,7 @@ import { decodePluginMentions, encodePluginMentions, transformPluginMentions, + transformExternalPluginMentions, validPluginMentions, type EditorPluginMention, } from './plugin-mentions.js'; @@ -461,7 +462,7 @@ export class Editor implements Component, Focusable { expandDraftPastes(encodePluginMentions(draft.text, this.pluginMentions), draft.pastes), ); const normalized = text.replace(/\r\n?/gu, '\n'); - const mentions = transformPluginMentions(previous.text, normalized, previous.mentions); + const mentions = transformExternalPluginMentions(previous.text, normalized, previous.mentions); this.setText(encodePluginMentions(normalized, mentions)); } diff --git a/packages/tui/src/tui/widgets/editor/plugin-mentions.ts b/packages/tui/src/tui/widgets/editor/plugin-mentions.ts index f734a5ff..ca882832 100644 --- a/packages/tui/src/tui/widgets/editor/plugin-mentions.ts +++ b/packages/tui/src/tui/widgets/editor/plugin-mentions.ts @@ -93,3 +93,42 @@ export function transformPluginMentions( 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-plugin-mentions.test.ts b/packages/tui/test/unit/tui-plugin-mentions.test.ts index 77525b41..fd0e26b3 100644 --- a/packages/tui/test/unit/tui-plugin-mentions.test.ts +++ b/packages/tui/test/unit/tui-plugin-mentions.test.ts @@ -474,6 +474,35 @@ describe('Plugin mention review regressions', () => { }, ); + 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) => { From faeedf0617f9a39dda45f0a2c5420e77be87635b Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 23 Sep 2026 17:55:33 +0800 Subject: [PATCH 5/6] fix: reconcile plugin mention display and simplify completion rows --- .../conversation/conversation-application.ts | 5 ++ .../conversation/direct-send-delivery.ts | 7 +- .../tui/src/application/run-coordinator.ts | 2 + .../src/tui/commands/plugin-autocomplete.ts | 22 ++++-- .../tui/src/tui/controller/chat-controller.ts | 1 + .../projection/turn-user-projection.ts | 14 +++- .../tui/test/unit/tui-chat-controller.test.ts | 46 +++++++++++- .../tui/test/unit/tui-plugin-mentions.test.ts | 70 ++++++++++++++++++- 8 files changed, 157 insertions(+), 10 deletions(-) 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/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/tui/commands/plugin-autocomplete.ts b/packages/tui/src/tui/commands/plugin-autocomplete.ts index 59c00fcb..abe63065 100644 --- a/packages/tui/src/tui/commands/plugin-autocomplete.ts +++ b/packages/tui/src/tui/commands/plugin-autocomplete.ts @@ -1,9 +1,11 @@ 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. */ @@ -52,7 +54,6 @@ export class TuiPluginAutocomplete implements AutocompleteProvider { (a, b) => a.displayName.localeCompare(b.displayName) || a.pluginId.localeCompare(b.pluginId), ) - .slice(0, 8) .map((plugin) => { const name = sanitizeTerminalText(plugin.displayName) @@ -64,13 +65,24 @@ export class TuiPluginAutocomplete implements AutocompleteProvider { value: `@${name}`, label: `@${name}`, pluginId: plugin.pluginId, - description: sanitizeTerminalText( - `Plugin · ${plugin.marketplace} · ${plugin.description ?? plugin.name}`, - ), + groupLabel: ' Plugins', + description: `${plugin.marketplace} · ${truncateToWidth( + sanitizeTerminalText(plugin.description ?? plugin.name) + .replace(/\s+/gu, ' ') + .trim(), + 64, + '…', + )}`, }; }) : []; - const combined = [...items, ...(fileSuggestions?.items ?? [])]; + const combined = [ + ...items, + ...(fileSuggestions?.items ?? []).map((item) => ({ + ...item, + ...(items.length > 0 ? { groupLabel: ' Files' } : {}), + })), + ]; return combined.length ? { prefix, items: combined } : null; } 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/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/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-plugin-mentions.test.ts b/packages/tui/test/unit/tui-plugin-mentions.test.ts index fd0e26b3..6ddf09f0 100644 --- a/packages/tui/test/unit/tui-plugin-mentions.test.ts +++ b/packages/tui/test/unit/tui-plugin-mentions.test.ts @@ -1,3 +1,8 @@ +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'; @@ -70,7 +75,7 @@ function createEditor() { async function selectPlugin(editor: Editor) { editor.handleInput('@'); await vi.waitFor(() => - expect(stripTerminalSequences(editor.render(80).join('\n'))).toContain('Plugin · local'), + expect(stripTerminalSequences(editor.render(80).join('\n'))).toContain('local · notes'), ); editor.handleInput('\t'); expect(editor.getText()).toBe('@My Notes '); @@ -80,6 +85,69 @@ async function selectPlugin(editor: Editor) { } 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(), From 54426081b748ee3549f622a022014865c16e6d52 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Wed, 23 Sep 2026 18:15:55 +0800 Subject: [PATCH 6/6] fix(tui): reconcile theme edits missed during watcher startup --- packages/tui/src/tui/theme/custom-themes.ts | 3 + .../test/unit/tui/theme/custom-themes.test.ts | 59 ++++++++++++++++++- .../tui/test/unit/tui/theme/runtime.test.ts | 17 ++++-- 3 files changed, 72 insertions(+), 7 deletions(-) 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/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(); }