diff --git a/README.md b/README.md index 8b1f14f2..88581350 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,10 @@ pi install npm:@tt-a1i/openpi OpenPI 会把长期进程放到后台,把独立任务交给隔离 Context 的 Pi Subagent,把多阶段依赖组织成 Workflow。状态会持续显示;完整运行可从 `/ps`、`/subagents` 和 `/workflows` 检查或终止。 > [!TIP] -> Capability discovery 默认 `explicit`:明确说出能力意图才会加载对应组。 -> 例如「在后台运行 dev server」→ 后台终端;「用/使用子代理检查」→ Subagent;「用工作流编排」→ Workflow;「用 fd/rg 搜索」或「用 git diff 比较分支」→ 搜索与只读 Git 工具。 +> Capability discovery 默认 `explicit`:明确说出能力意图才会加载对应组。英文 `subagent` 与 `workflow` 是保留授权词,单独输入也会加载对应能力。 +> 例如 `subagent, workflow` → 同时加载两组;「在后台运行 dev server」→ 后台终端;「用/使用子代理检查」→ Subagent;「用工作流编排」→ Workflow;「用 fd/rg 搜索」或「用 git diff 比较分支」→ 搜索与只读 Git 工具。 > 关键是把意图说清楚(说「用子代理」「后台运行」这类带动作的短语),不需要记住任何工具名。 +> 在交互输入框中,保留词 `Subagent` / `Workflow`,以及已被识别的中文能力请求,会使用 Claude Code 风格的薰衣草紫显示;浅色终端自动使用更深的紫色以维持可读性。变色表示提交后会加载对应能力。因为英文名称本身就是授权词,讨论中写出它们也会开闸;条件句和否定句仍保持普通显示,Suggestion 幽灵文字也要在用户接受进输入框后才参与识别。 > [!IMPORTANT] > 默认安装是安静的:不改主题、不绑定 Provider 或模型、不开启下一步预测,也不执行 post-edit 命令。Capability discovery 默认 `explicit`;只有用户通过 `/openpi-setup` 选择 `adaptive` 后,模型才会常驻看到一个小型发现网关并可自主加载额外能力。 diff --git a/SETUP.md b/SETUP.md index 60363713..a2fcf441 100644 --- a/SETUP.md +++ b/SETUP.md @@ -54,7 +54,7 @@ Use the single canonical package-owned command. `/my-pi-setup` remains a compati /openpi-setup 清除 explorer 的模型,让它继承父模型 ``` -Capability discovery defaults to `explicit`, preserving the zero-resident OpenPI tool surface until the user asks for a capability. `adaptive` is an explicit opt-in that keeps only `openpi_load_tools` visible and allows the model to load a useful group on its own; because this can start Subagents, Workflows, or background processes, normal permission and configured concurrency/call limits still apply. Changing the setting updates the current Session immediately, while already loaded groups remain stable for that Session. Next-action suggestions default to off. Run `/openpi-setup` to explicitly choose an available model and reasoning level. After a fully settled main-agent run, one suggestion may appear as dim inline text on the first row of an empty editor; reserved cells at the row end keep CJK IME preedit from overwriting it. `Right` accepts it into the editor without submitting, while any other editor input dismisses it. Suggestions are ephemeral and never enter session history or model context. Workflows default to 8 concurrent agents and 128 total agent calls per run; configurable hard maxima are 64 and 1024. The large decorative header defaults off and the custom dashboard footer defaults on with a one-line plain layout (`cwd git pr |flex| model context`; `thinking`, `cache`, `cost`, and `throughput` remain opt-in metrics). Footer presets are `powerline`, `powerline-mono`, and `compact`; style can also be set independently to `plain`, `powerline`, or `powerline-mono`. Custom layouts use a 2D `footerLines` array with at most one `flex` per row for left/right alignment. Nerd Font affects powerline separator glyphs (``) and adds Codex-style outline icons to compact read, terminal, edit, search, and directory activity rows; all accompanying text remains readable without it. Metrics carry small built-in Unicode icons (`📁` directory, `⎇` branch, `✦` model, and a `◔◑◕●` context gauge), all of which render in standard terminal fonts. Footer changes apply immediately in the active TUI session. Subagent results default to the existing full display; compact mode shows only bounded status rows and keeps raw child reports behind `app.tools.expand` (`Ctrl+O` by default). Ordinary `read`, `grep`, `find`, and `ls` operations render as one-line semantic activity summaries. Bash and Write/Edit default to the same activity-row projection, showing the target, running/success/failure state, and useful line or diff counts without replaying stdout or previews. Select full independently for Bash or Write/Edit to keep Pi's native rendering expanded. `app.tools.expand` temporarily restores the native arguments, output, errors, diff, timing, images, truncation notices, and full-output metadata; it never changes Session history or model context. An optional post-edit command is off by default: set one (for example `npm run format`, maximum 500 characters) and it runs once in the background after each interactive-TUI turn with successful Write/Edit operations, with failures reported as a notification. It deliberately does not guess whether arbitrary Bash commands changed files. Built-in Agent roles `explorer`, `implementer`, `reviewer`, and `advisor` are shared by `subagent_spawn.agent_type` and Workflow `agent(..., { agent_type })`; all inherit the parent model by default. `/openpi-setup` may assign a currently available Registry model to any subset; clearing one returns it to inheritance and omitted roles stay unchanged. Model precedence is explicit call > selected role-file model > setup assignment > parent inheritance; effort is explicit call > selected role > parent. A trusted project `.pi/agents/.md` overrides global `~/.pi/agent/agents/.md`, which overrides the complete built-in role definition; overrides are diagnosed. Role-model changes apply to the next spawn or Workflow agent call without reload. Configuration is stored privately at `~/.pi/agent/my-pi-setup.json`. +Capability discovery defaults to `explicit`, preserving the zero-resident OpenPI tool surface until the user asks for a capability. The case-insensitive English words `subagent` and `workflow` are reserved authorization words: entering either word is sufficient to load its capability group, and the interactive editor shows it in Claude Code-style lavender before submission, with a darker purple fallback for light themes. This makes discussion that contains either English word an intentional opt-in tradeoff; conditional and negated clauses remain inert, while Chinese capability names still require a recognized action request. `adaptive` is an explicit opt-in that keeps only `openpi_load_tools` visible and allows the model to load a useful group on its own; because this can start Subagents, Workflows, or background processes, normal permission and configured concurrency/call limits still apply. Changing the setting updates the current Session immediately, while already loaded groups remain stable for that Session. Accepted Suggestion text is classified only after it becomes real editor input. The visual feedback and runtime activation share one fail-closed intent classifier. Next-action suggestions default to off. Run `/openpi-setup` to explicitly choose an available model and reasoning level. After a fully settled main-agent run, one suggestion may appear as dim inline text on the first row of an empty editor; reserved cells at the row end keep CJK IME preedit from overwriting it. `Right` accepts it into the editor without submitting, while any other editor input dismisses it. Suggestions are ephemeral and never enter session history or model context. Workflows default to 8 concurrent agents and 128 total agent calls per run; configurable hard maxima are 64 and 1024. The large decorative header defaults off and the custom dashboard footer defaults on with a one-line plain layout (`cwd git pr |flex| model context`; `thinking`, `cache`, `cost`, and `throughput` remain opt-in metrics). Footer presets are `powerline`, `powerline-mono`, and `compact`; style can also be set independently to `plain`, `powerline`, or `powerline-mono`. Custom layouts use a 2D `footerLines` array with at most one `flex` per row for left/right alignment. Nerd Font affects powerline separator glyphs (``) and adds Codex-style outline icons to compact read, terminal, edit, search, and directory activity rows; all accompanying text remains readable without it. Metrics carry small built-in Unicode icons (`📁` directory, `⎇` branch, `✦` model, and a `◔◑◕●` context gauge), all of which render in standard terminal fonts. Footer changes apply immediately in the active TUI session. Subagent results default to the existing full display; compact mode shows only bounded status rows and keeps raw child reports behind `app.tools.expand` (`Ctrl+O` by default). Ordinary `read`, `grep`, `find`, and `ls` operations render as one-line semantic activity summaries. Bash and Write/Edit default to the same activity-row projection, showing the target, running/success/failure state, and useful line or diff counts without replaying stdout or previews. Select full independently for Bash or Write/Edit to keep Pi's native rendering expanded. `app.tools.expand` temporarily restores the native arguments, output, errors, diff, timing, images, truncation notices, and full-output metadata; it never changes Session history or model context. An optional post-edit command is off by default: set one (for example `npm run format`, maximum 500 characters) and it runs once in the background after each interactive-TUI turn with successful Write/Edit operations, with failures reported as a notification. It deliberately does not guess whether arbitrary Bash commands changed files. Built-in Agent roles `explorer`, `implementer`, `reviewer`, and `advisor` are shared by `subagent_spawn.agent_type` and Workflow `agent(..., { agent_type })`; all inherit the parent model by default. `/openpi-setup` may assign a currently available Registry model to any subset; clearing one returns it to inheritance and omitted roles stay unchanged. Model precedence is explicit call > selected role-file model > setup assignment > parent inheritance; effort is explicit call > selected role > parent. A trusted project `.pi/agents/.md` overrides global `~/.pi/agent/agents/.md`, which overrides the complete built-in role definition; overrides are diagnosed. Role-model changes apply to the next spawn or Workflow agent call without reload. Configuration is stored privately at `~/.pi/agent/my-pi-setup.json`. ## Session Goal and Tasks diff --git a/extensions/capabilities/index.test.ts b/extensions/capabilities/index.test.ts index fd96a22a..06f339f1 100644 --- a/extensions/capabilities/index.test.ts +++ b/extensions/capabilities/index.test.ts @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; import { SETUP_CONFIG_CHANGED_CHANNEL } from "../shared/setup-config.ts"; import { OPENPI_TOOL_SURFACE, @@ -56,7 +59,7 @@ function harness(options: { discovery?: "explicit" | "adaptive" } = {}) { ]; let active = [...available]; const tools = new Map(); - const starts: (() => void)[] = []; + const starts: Array<(event: unknown, ctx: ExtensionContext) => void> = []; const beforeStarts: ((event: { prompt: string }) => unknown)[] = []; const eventHandlers = new Map void>>(); const pi = { @@ -75,7 +78,9 @@ function harness(options: { discovery?: "explicit" | "adaptive" } = {}) { event: string, handler: (() => void) | ((event: { prompt: string }) => unknown), ) { - if (event === "session_start") starts.push(handler as () => void); + if (event === "session_start") { + starts.push(handler as (event: unknown, ctx: ExtensionContext) => void); + } if (event === "before_agent_start") { beforeStarts.push(handler as (event: { prompt: string }) => unknown); } @@ -116,7 +121,8 @@ function harness(options: { discovery?: "explicit" | "adaptive" } = {}) { return { active: () => [...active], start: () => { - for (const handler of starts) handler(); + const ctx = { mode: "json" } as ExtensionContext; + for (const handler of starts) handler({}, ctx); }, before: (prompt: string) => { return beforeStarts.map((handler) => handler({ prompt })); @@ -235,8 +241,22 @@ test("an explicit subagent request loads delegation directly", () => { assert.match(JSON.stringify(results), /skills\/subagents\/SKILL\.md/); }); +test("reserved capability words load their groups without action verbs", () => { + const h = harness(); + h.start(); + + h.before("subagent, workflow"); + + assert.ok(h.active().includes("subagent_spawn")); + assert.ok(h.active().includes("workflow")); +}); + test("common Chinese and multi-agent delegation requests are explicit intent", () => { - for (const prompt of ["来多子代理一起讨论", "Use multiple subagents."]) { + for (const prompt of [ + "来多子代理一起讨论", + "Use multiple subagents.", + "用 Subagent 检查这个实现", + ]) { const h = harness(); h.start(); diff --git a/extensions/capabilities/index.ts b/extensions/capabilities/index.ts index 8e042336..25835ecd 100644 --- a/extensions/capabilities/index.ts +++ b/extensions/capabilities/index.ts @@ -1,6 +1,14 @@ import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { type Static, Type } from "typebox"; +import { + capabilitiesRequestedByPrompt, + requestsCapabilityGateway, +} from "../shared/capability-intent.ts"; +import { + registerEditorLayer, + removeEditorLayer, +} from "../shared/editor-layers.ts"; import { loadSetupConfig, type MyPiSetupConfig, @@ -16,6 +24,11 @@ import { patchOwnedTools, resetOpenPiToolSurface, } from "../shared/tool-surface.ts"; +import { + CapabilityIntentHighlightEditor, + colorCapabilityKeyword, + isLightNamedTheme, +} from "./src/ui.ts"; const CapabilitySchema = Type.Unsafe({ type: "string", @@ -35,25 +48,6 @@ const OpenPiLoadToolsParameters = Type.Object({ type OpenPiLoadToolsInput = Static; -const CAPABILITY_INTENT = { - search: - /\b(?:use|run)\s+(?:fd|rg)\b|\buse\s+(?:structured\s+)?(?:(?:file|code|content)\s+)?search\b|\b(?:structured|fast)\s+(?:file|code|content)\s+search\b|(?:使用|用|运行).{0,8}(?:fd|rg|git\s+(?:show|diff|log))|结构化(?:文件|代码|内容)搜索/iu, - delegate: - /\b(?:use|spawn|run)\s+(?:an?\s+|multiple\s+|several\s+|two\s+)?(?:pi\s+)?subagents?\b|(?:^|[.!?]\s+)(?:please\s+)?(?:delegate|parallelize)\s+(?:this|the)\s+(?:task|work)\b|\b(?:can|could|would)\s+you\s+(?:please\s+)?(?:delegate|parallelize)\s+(?:this|the)\s+(?:task|work)\b|\bparallel\s+agents?\b|(?:使用|用|启动|调用|来|开).{0,8}子代理|(?:多个?|多路)子代理|并行.{0,8}(?:代理|agent)|委派.{0,6}(?:任务|给|出去)/iu, - workflow: - /\b(?:use|run|create|build)\s+(?:(?:an?|the)\s+)?(?:openpi\s+)?workflow\b|(?:使用|用|运行|创建|构建).{0,8}工作流|用.{0,4}workflow/iu, - background: - /\b(?:run|start|keep)\b.{0,40}\b(?:in the background|background\s+(?:process|terminal|job))\b|后台.{0,8}(?:运行|启动|进程|终端|任务)/iu, - session: - /\b(?:create|set|update|track)\s+(?:an?\s+)?(?:session\s+)?(?:goal|task list|tasks)\b|(?:设置|创建|更新|跟踪|追踪).{0,8}(?:目标|任务)/iu, -} as const satisfies Record; - -const CAPABILITY_GATEWAY_INTENT = - /\bopenpi\s+(?:capabilit(?:y|ies)|tools?|features?)\b|openpi.{0,8}(?:能力|工具|功能)/iu; - -const CONDITIONAL_OR_NEGATED_INTENT = - /^(?:\s*(?:only\s+)?(?:if|when|unless|before|in case)\b)|\b(?:do not|don't|cannot|can't|not|no|never|avoid)\b|\b(?:if|unless)\b|\bwhen\s+(?:needed|required|necessary)\b|(?:如果|若|假如|除非|仅当|需要时|不要|不能|不用|不必|无需|避免|请勿|禁止)/iu; - const CAPABILITY_SKILLS: Partial> = { delegate: fileURLToPath( new URL("../../skills/subagents/SKILL.md", import.meta.url), @@ -66,27 +60,6 @@ const CAPABILITY_SKILLS: Partial> = { ), }; -function capabilitiesRequestedByPrompt(prompt: string) { - const clauses = prompt.split(/[\n.!?。!?;;]+/u); - return OPENPI_CAPABILITY_NAMES.filter((capability) => - clauses.some( - (clause) => - !CONDITIONAL_OR_NEGATED_INTENT.test(clause) && - CAPABILITY_INTENT[capability].test(clause), - ), - ); -} - -function requestsCapabilityGateway(prompt: string) { - return prompt - .split(/[\n.!?。!?;;]+/u) - .some( - (clause) => - !CONDITIONAL_OR_NEGATED_INTENT.test(clause) && - CAPABILITY_GATEWAY_INTENT.test(clause), - ); -} - function capabilitySkillPaths(capabilities: readonly OpenPiCapability[]) { return capabilities.flatMap((capability) => { const skill = CAPABILITY_SKILLS[capability]; @@ -124,7 +97,7 @@ export function createCapabilitiesExtension( pi.events.on(SETUP_CONFIG_CHANGED_CHANNEL, reconcileDiscoveryGateway); - pi.on("session_start", () => { + pi.on("session_start", (_event, ctx) => { resetOpenPiToolSurface( pi, dependencies.sourcePath @@ -132,6 +105,21 @@ export function createCapabilitiesExtension( : undefined, ); reconcileDiscoveryGateway(); + registerEditorLayer(pi, ctx, { + id: "capability-intent-highlight", + order: 150, + wrap: (base, _tui, _theme, keybindings) => + new CapabilityIntentHighlightEditor(base, keybindings, (text) => + colorCapabilityKeyword(text, { + colorMode: ctx.ui.theme.getColorMode(), + light: isLightNamedTheme(ctx.ui.theme.name), + }), + ), + }); + }); + + pi.on("session_shutdown", () => { + removeEditorLayer(pi, "capability-intent-highlight"); }); pi.on("before_agent_start", (event) => { diff --git a/extensions/capabilities/src/ui.ts b/extensions/capabilities/src/ui.ts new file mode 100644 index 00000000..e8a7921f --- /dev/null +++ b/extensions/capabilities/src/ui.ts @@ -0,0 +1,93 @@ +import type { KeybindingsManager } from "@earendil-works/pi-coding-agent"; +import type { EditorComponent } from "@earendil-works/pi-tui"; +import { + BelowEditorNavigationEditor, + BelowEditorStripState, +} from "../../shared/below-editor-navigation.ts"; +import { capabilitiesRequestedByPrompt } from "../../shared/capability-intent.ts"; + +const DELEGATE_NAMES = /\bsubagents?\b|子代理/giu; +const WORKFLOW_NAMES = /\bworkflows?\b|工作流/giu; +const FOREGROUND_RESET = "\u001b[39m"; + +interface CapabilityKeywordColorOptions { + readonly colorMode: "truecolor" | "256color"; + readonly light: boolean; +} + +export function isLightNamedTheme(name: string | undefined) { + return name !== undefined && /(?:^|[-_])light(?:$|[-_])/iu.test(name); +} + +/** + * Claude-style lavender keyword color. The light variant preserves readable + * contrast instead of mechanically reusing the bright dark-terminal swatch. + */ +export function colorCapabilityKeyword( + text: string, + options: CapabilityKeywordColorOptions, +) { + const start = options.light + ? options.colorMode === "truecolor" + ? "\u001b[38;2;130;80;223m" + : "\u001b[38;5;98m" + : options.colorMode === "truecolor" + ? "\u001b[38;2;210;168;255m" + : "\u001b[38;5;183m"; + return `${start}${text}${FOREGROUND_RESET}`; +} + +export function highlightCapabilityNames( + line: string, + capabilities: readonly string[], + highlight: (text: string) => string, +) { + let result = line; + if (capabilities.includes("delegate")) { + result = result.replace(DELEGATE_NAMES, (match) => highlight(match)); + } + if (capabilities.includes("workflow")) { + result = result.replace(WORKFLOW_NAMES, (match) => highlight(match)); + } + return result; +} + +/** + * Transparent, pre-submit feedback for capability intent. It colours only + * names whose capability the shared classifier would load after submission; + * it never changes editor text, Session history, or model context. + */ +export class CapabilityIntentHighlightEditor extends BelowEditorNavigationEditor { + private readonly highlight: (text: string) => string; + + constructor( + base: EditorComponent, + keybindings: KeybindingsManager, + highlight: (text: string) => string, + ) { + super( + base, + keybindings, + new BelowEditorStripState(), + () => false, + () => undefined, + () => undefined, + ); + this.highlight = highlight; + } + + override render(width: number) { + const capabilities = capabilitiesRequestedByPrompt(this.getText()); + if ( + !capabilities.includes("delegate") && + !capabilities.includes("workflow") + ) { + return super.render(width); + } + return super + .render(width) + .map((line) => + highlightCapabilityNames(line, capabilities, this.highlight), + ); + } +} diff --git a/extensions/capabilities/ui.test.ts b/extensions/capabilities/ui.test.ts new file mode 100644 index 00000000..d654f7c9 --- /dev/null +++ b/extensions/capabilities/ui.test.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { KeybindingsManager } from "@earendil-works/pi-coding-agent"; +import type { EditorComponent } from "@earendil-works/pi-tui"; +import { CURSOR_MARKER } from "@earendil-works/pi-tui"; +import { + NextActionSuggestionEditor, + NextActionSuggestionState, +} from "../suggestions/src/ui.ts"; +import { + CapabilityIntentHighlightEditor, + colorCapabilityKeyword, + highlightCapabilityNames, + isLightNamedTheme, +} from "./src/ui.ts"; + +function baseEditor(initial: string): EditorComponent { + let text = initial; + return { + render: () => [text], + invalidate() {}, + handleInput() {}, + getText: () => text, + setText(value: string) { + text = value; + }, + }; +} + +function editor(initial: string) { + const base = baseEditor(initial); + return { + base, + highlighted: new CapabilityIntentHighlightEditor( + base, + {} as KeybindingsManager, + (text) => `${text}`, + ), + }; +} + +test("uses Claude-style lavender with a contrast-safe light variant", () => { + assert.equal(isLightNamedTheme("light"), true); + assert.equal(isLightNamedTheme("github-light-default"), true); + assert.equal(isLightNamedTheme("dark"), false); + assert.equal(isLightNamedTheme(undefined), false); + + assert.equal( + colorCapabilityKeyword("subagent", { + colorMode: "truecolor", + light: false, + }), + "\u001b[38;2;210;168;255msubagent\u001b[39m", + ); + assert.equal( + colorCapabilityKeyword("workflow", { + colorMode: "256color", + light: false, + }), + "\u001b[38;5;183mworkflow\u001b[39m", + ); + assert.equal( + colorCapabilityKeyword("subagent", { + colorMode: "truecolor", + light: true, + }), + "\u001b[38;2;130;80;223msubagent\u001b[39m", + ); + assert.equal( + colorCapabilityKeyword("workflow", { + colorMode: "256color", + light: true, + }), + "\u001b[38;5;98mworkflow\u001b[39m", + ); +}); + +test("highlights capability names only when shared intent authorizes them", () => { + const reserved = editor("subagent, workflow"); + assert.deepEqual(reserved.highlighted.render(120), [ + "subagent, workflow", + ]); + + const explicit = editor("用 Subagent 检查,再用 Workflow 汇总"); + assert.deepEqual(explicit.highlighted.render(120), [ + "用 Subagent 检查,再用 Workflow 汇总", + ]); + + const named = editor("Subagent 和 Workflow 有什么区别?"); + assert.deepEqual(named.highlighted.render(120), [ + "SubagentWorkflow 有什么区别?", + ]); + + const negated = editor("不要用 Subagent,也不要用 Workflow"); + assert.deepEqual(negated.highlighted.render(120), [ + "不要用 Subagent,也不要用 Workflow", + ]); +}); + +test("supports English plurals and Chinese capability names", () => { + assert.equal( + highlightCapabilityNames( + "Use Subagents and 子代理", + ["delegate"], + (text) => `[${text}]`, + ), + "Use [Subagents] and [子代理]", + ); + assert.equal( + highlightCapabilityNames( + "运行 Workflows 和工作流", + ["workflow"], + (text) => `[${text}]`, + ), + "运行 [Workflows] 和[工作流]", + ); +}); + +test("render feedback never mutates the submitted editor text", () => { + const value = "用 Subagent 检查"; + const current = editor(value); + current.highlighted.render(80); + assert.equal(current.highlighted.getText(), value); + assert.equal(current.base.getText(), value); +}); + +test("ghost suggestions stay dim until the user accepts them into the editor", () => { + let text = ""; + const base = { + render: (width: number) => [ + text || `${CURSOR_MARKER}\u001b[7m \u001b[0m${" ".repeat(width - 1)}`, + ], + invalidate() {}, + handleInput() {}, + getText: () => text, + setText(value: string) { + text = value; + }, + } satisfies EditorComponent; + const highlighted = new CapabilityIntentHighlightEditor( + base, + {} as KeybindingsManager, + (value) => `${value}`, + ); + const state = new NextActionSuggestionState(); + const suggestion = new NextActionSuggestionEditor( + highlighted, + {} as KeybindingsManager, + state, + () => state.cancel(), + () => undefined, + (value) => `${value}`, + ); + state.offer(state.begin(), "用 Subagent 检查", true); + + assert.doesNotMatch(suggestion.render(80).join("\n"), //u); + + suggestion.handleInput("\u001b[C"); + assert.equal(base.getText(), "用 Subagent 检查"); + assert.match(suggestion.render(80).join("\n"), /Subagent<\/accent>/u); +}); diff --git a/extensions/shared/capability-intent.test.ts b/extensions/shared/capability-intent.test.ts new file mode 100644 index 00000000..e4f14f74 --- /dev/null +++ b/extensions/shared/capability-intent.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + capabilitiesRequestedByPrompt, + requestsCapabilityGateway, +} from "./capability-intent.ts"; + +test("classifies explicit capability intent across English, Chinese, and mixed phrases", () => { + assert.deepEqual(capabilitiesRequestedByPrompt("subagent, workflow"), [ + "delegate", + "workflow", + ]); + assert.deepEqual( + capabilitiesRequestedByPrompt("用 Subagent 并行检查这三个模块"), + ["delegate"], + ); + assert.deepEqual( + capabilitiesRequestedByPrompt("Use subagents to review this change"), + ["delegate"], + ); + assert.deepEqual( + capabilitiesRequestedByPrompt("用 Workflow 分阶段实现和审查"), + ["workflow"], + ); + assert.deepEqual( + capabilitiesRequestedByPrompt("Run a workflow for implementation"), + ["workflow"], + ); +}); + +test("reserved English capability words authorize the matching groups", () => { + assert.deepEqual( + capabilitiesRequestedByPrompt("Compare Subagent and Workflow"), + ["delegate", "workflow"], + ); +}); + +test("negation and conditional language remain fail-closed", () => { + for (const prompt of [ + "Do not use subagents.", + "If needed, run a workflow.", + "如果需要,可以用 Subagent。", + "不要用 Workflow。", + ]) { + assert.deepEqual(capabilitiesRequestedByPrompt(prompt), [], prompt); + } +}); + +test("gateway intent shares the same negation policy", () => { + assert.equal(requestsCapabilityGateway("Show OpenPI capabilities."), true); + assert.equal(requestsCapabilityGateway("Do not use OpenPI tools."), false); +}); diff --git a/extensions/shared/capability-intent.ts b/extensions/shared/capability-intent.ts new file mode 100644 index 00000000..743761a3 --- /dev/null +++ b/extensions/shared/capability-intent.ts @@ -0,0 +1,53 @@ +import { + OPENPI_CAPABILITY_NAMES, + type OpenPiCapability, +} from "./tool-surface.ts"; + +const CAPABILITY_INTENT = { + search: + /\b(?:use|run)\s+(?:fd|rg)\b|\buse\s+(?:structured\s+)?(?:(?:file|code|content)\s+)?search\b|\b(?:structured|fast)\s+(?:file|code|content)\s+search\b|(?:使用|用|运行).{0,8}(?:fd|rg|git\s+(?:show|diff|log))|结构化(?:文件|代码|内容)搜索/iu, + delegate: + /\bsubagents?\b|(?:^|[.!?]\s+)(?:please\s+)?(?:delegate|parallelize)\s+(?:this|the)\s+(?:task|work)\b|\b(?:can|could|would)\s+you\s+(?:please\s+)?(?:delegate|parallelize)\s+(?:this|the)\s+(?:task|work)\b|\bparallel\s+agents?\b|(?:使用|用|启动|调用|来|开).{0,8}子代理|(?:多个?|多路)子代理|并行.{0,8}(?:代理|agent)|委派.{0,6}(?:任务|给|出去)/iu, + workflow: /\bworkflows?\b|(?:使用|用|运行|创建|构建).{0,8}工作流/iu, + background: + /\b(?:run|start|keep)\b.{0,40}\b(?:in the background|background\s+(?:process|terminal|job))\b|后台.{0,8}(?:运行|启动|进程|终端|任务)/iu, + session: + /\b(?:create|set|update|track)\s+(?:an?\s+)?(?:session\s+)?(?:goal|task list|tasks)\b|(?:设置|创建|更新|跟踪|追踪).{0,8}(?:目标|任务)/iu, +} as const satisfies Record; + +const CAPABILITY_GATEWAY_INTENT = + /\bopenpi\s+(?:capabilit(?:y|ies)|tools?|features?)\b|openpi.{0,8}(?:能力|工具|功能)/iu; + +const CONDITIONAL_OR_NEGATED_INTENT = + /^(?:\s*(?:only\s+)?(?:if|when|unless|before|in case)\b)|\b(?:do not|don't|cannot|can't|not|no|never|avoid)\b|\b(?:if|unless)\b|\bwhen\s+(?:needed|required|necessary)\b|(?:如果|若|假如|除非|仅当|需要时|不要|不能|不用|不必|无需|避免|请勿|禁止)/iu; + +function clauses(prompt: string) { + return prompt.split(/[\n.!?。!?;;]+/u); +} + +function isExplicitClause(clause: string) { + return !CONDITIONAL_OR_NEGATED_INTENT.test(clause); +} + +/** + * One fail-closed interpretation of explicit user capability intent. The + * English names `subagent` and `workflow` are reserved authorization words; + * negated or conditional clauses remain inert. Runtime activation and + * pre-submit UI feedback both cross this seam, so they cannot drift. + */ +export function capabilitiesRequestedByPrompt(prompt: string) { + const promptClauses = clauses(prompt); + return OPENPI_CAPABILITY_NAMES.filter((capability) => + promptClauses.some( + (clause) => + isExplicitClause(clause) && CAPABILITY_INTENT[capability].test(clause), + ), + ); +} + +export function requestsCapabilityGateway(prompt: string) { + return clauses(prompt).some( + (clause) => + isExplicitClause(clause) && CAPABILITY_GATEWAY_INTENT.test(clause), + ); +} diff --git a/extensions/shared/editor-layers.test.ts b/extensions/shared/editor-layers.test.ts index 185ffbe3..5aa463de 100644 --- a/extensions/shared/editor-layers.test.ts +++ b/extensions/shared/editor-layers.test.ts @@ -5,6 +5,7 @@ import type { ExtensionContext, ExtensionFactory, } from "@earendil-works/pi-coding-agent"; +import { createCapabilitiesExtension } from "../capabilities/index.ts"; import subagents from "../subagents/index.ts"; import suggestions from "../suggestions/index.ts"; import workflows from "../workflows/index.ts"; @@ -69,6 +70,11 @@ function editorLifecycleHarness() { }; load(subagents); + load( + createCapabilitiesExtension({ + loadConfig: () => ({ capabilities: { discovery: "explicit" } }), + }), + ); load(suggestions); load(workflows);