From 195794138cd1fdb2fd6e5fbfd74dc602708ae4cd Mon Sep 17 00:00:00 2001 From: jinpy666 Date: Mon, 28 Sep 2026 13:35:14 +0800 Subject: [PATCH 01/22] =?UTF-8?q?docs(completion):=20FIG=20wave-1=20?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E2=80=94=E2=80=94=E8=8C=83=E5=9B=B4/?= =?UTF-8?q?=E6=80=BB=E7=BA=BF=E5=86=B3=E7=AD=96/=E5=86=BB=E7=BB=93?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B/RPC=20=E7=BA=BF=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...FIG_AUTOCOMPLETE_INTEGRATION_PLAN.zh-CN.md | 2817 +++++++++++++++++ docs/FIG_WAVE1_CONTRACT.zh-CN.md | 98 + frontend/src/lib/completion/core/types.ts | 82 + frontend/src/lib/completion/host/protocol.ts | 29 + 4 files changed, 3026 insertions(+) create mode 100644 docs/FIG_AUTOCOMPLETE_INTEGRATION_PLAN.zh-CN.md create mode 100644 docs/FIG_WAVE1_CONTRACT.zh-CN.md create mode 100644 frontend/src/lib/completion/core/types.ts create mode 100644 frontend/src/lib/completion/host/protocol.ts diff --git a/docs/FIG_AUTOCOMPLETE_INTEGRATION_PLAN.zh-CN.md b/docs/FIG_AUTOCOMPLETE_INTEGRATION_PLAN.zh-CN.md new file mode 100644 index 00000000..575bbe04 --- /dev/null +++ b/docs/FIG_AUTOCOMPLETE_INTEGRATION_PLAN.zh-CN.md @@ -0,0 +1,2817 @@ +# dbx-plugin-ssh:Fig / Amazon Q Completion Spec 集成实施文档 + +> 基线:`codex/ssh/fix-120-suggestion-overlay` 当前分支,HEAD `3064b8372915876d8f91e806f65ee7f4ad8e0fe6`。 +> +> 目标:在现有 Rust sidecar + Vue/Vite + xterm.js 架构上,引入 Fig / Amazon Q 生态的 Completion Spec,并实现本地、SSH、Windows、Linux、macOS、WSL 等多平台目标环境的统一补全运行时。 + +--- + +## 1. 实施结论 + +本项目**不要集成 Fig Desktop / figterm / Fig React UI**,而应该集成下面三个能力: + +1. `withfig/autocomplete` 的 Spec 数据格式与 spec corpus; +2. Amazon Q autocomplete 项目中的 parser / resolver / generator 运行时设计; +3. 由 dbx-plugin-ssh 自己提供的 `CompletionHost`,把“动态生成候选”执行到正确的本地或远端 target 上。 + +最终结构: + +```text + ┌──────────────────────────┐ + │ withfig/autocomplete │ + │ Fig.Spec / Fig.Generator │ + └────────────┬─────────────┘ + │ build-time + ▼ + ┌──────────────────────────┐ + │ Fig Spec Bundle │ + │ command -> spec module │ + └────────────┬─────────────┘ + │ + ▼ +┌───────────────┐ ┌──────────────────────────┐ +│ xterm.js │──────▶│ CompletionController │ +│ onData/key │ │ edit-buffer + revision │ +└──────┬────────┘ └────────────┬─────────────┘ + │ │ Worker RPC + │ ▼ + │ ┌────────────────────────┐ + │ │ Completion Worker │ + │ │ parser / resolver │ + │ │ generator scheduler │ + │ │ ranking / replacement │ + │ └───────────┬────────────┘ + │ │ CompletionHost RPC + │ ▼ + │ ┌────────────────────────┐ + │ │ Rust sidecar │ + │ │ target dispatch │ + │ └───────┬───────┬────────┘ + │ │ │ + ▼ ▼ ▼ + Completion UI Local SSH / WSL / future + Vue overlay executor remote executor +``` + +核心原则只有一条: + +> **Desktop OS 与 Completion Target OS 完全解耦。** +> +> macOS 上的 SSH Linux 会话,补全 generator 必须在 Linux 远端执行;Windows 上的 SSH Linux 会话也必须同样执行在 Linux 远端。不能因为前端运行在 Windows 就让 generator 在 Windows 本机执行。 + +--- + +## 2. 当前代码基线 + +当前分支已经具备完整的终端基础设施,集成点非常明确。 + +### 2.1 已有完成项 + +当前 `frontend/src/lib/completions/spec.ts` 已有: + +- `CompletionSpecs` +- `CompletionFlagSpec` +- `CompletionPositionalSpec` +- `SpecCommand` +- `CompletionRow` +- `SpecMatch` +- `splitCommandLine()` +- `matchSpecLine()` +- `replaceStart / replaceEnd` +- `--flag=value` 解析 +- 引号 / 转义 / `--` terminator 处理 + +当前 `frontend/src/lib/completions/provider.ts` 已经提供动态候选 provider 注册接口。 + +当前 `frontend/src/components/CompletionMenu.vue` 已经处理: + +- `--popover / --border / --accent / --foreground` theme token; +- above / below 翻转; +- 两侧都不够时 `max-height + overflow-y`; +- 光标字符右边缘 + 6px 水平间隙; +- terminal host 净高度; +- `scrollHeight` 测量自然高度。 + +当前 `App.vue` 已经做到: + +- `pendingTerminalInput`; +- `matchSpecLine()` 优先、历史建议 fallback; +- completion 与 ghost 互斥; +- Enter 默认不被结构化补全吞掉; +- 动态 hint 无候选时 Tab 透传 shell; +- replacement 使用 parser 的 `replaceStart / replaceEnd`; +- 输出 settle 后通过 rAF 重新测量 anchor,解决 SSH RTT 导致的定位滞后。 + +当前 sidecar 已经有: + +- SSH PTY; +- local PTY; +- `ssh/exec`; +- local shell discovery; +- local filesystem browse; +- SSH SFTP; +- Windows ConPTY; +- shell integration / OSC 7 / OSC 633; +- binary terminal input/output channel。 + +这意味着**无需引入 figterm 的 PTY 拦截层**。现有 PTY 和 xterm.js 已经承担了 figterm 的宿主职责。 + +--- + +## 3. 与 Fig / Amazon Q 的正确关系 + +### 3.1 Spec 来源 + +`withfig/autocomplete` 中的核心资产是 Completion Spec:命令、subcommands、options、args、description 以及 generator。这个格式仍然是后续兼容性的主要来源。 + +官方仓库中的 spec 仍采用 `Fig.Spec` / `Fig.Option` / `Fig.Generator` 形态,例如 git、cf、helm 等 spec,generator 可以通过脚本执行目标 CLI 并对输出做 `postProcess`。这说明这里不是一个“JSON 字典格式”,而是一个包含运行时代码语义的 TypeScript spec 系统。 + +### 3.2 Parser 来源 + +Amazon Q Developer CLI autocomplete 项目继续提供 TypeScript parser / autocomplete runtime,并以 Rust + TypeScript workspace 组织。该项目的 root `package.json` 当前使用 Node 22 + pnpm,许可证为 MIT OR Apache-2.0。 + +**实施要求:** + +- 以当前 Amazon Q autocomplete parser 源码作为兼容参考; +- 不依赖旧版本 npm parser 包作为最终方案; +- parser 最终运行在 Web Worker; +- Rust 只提供 host capabilities,不复制 Fig parser 的语义。 + +### 3.3 为什么不能直接把 Fig Spec 映射成当前的 `SpecCommand` + +当前模型只覆盖: + +```text +command + ├── subcommands + ├── flags + └── positional +``` + +而 Fig Spec 实际还涉及: + +- 多别名 `name: ["checkout", "co"]`; +- persistent options; +- repeatable options; +- variadic args; +- exclusive / depends-on; +- `isDangerous` / `priority`; +- `insertValue`; +- option separator `--`; +- nested `args` / `options`; +- generator; +- `loadSpec`; +- `generateSpec`; +- parser directives; +- 自定义 completion generator; +- 动态脚本输出后处理。 + +因此当前 `spec.ts` 应当变成**兼容层 / UI adapter**,而不再是未来的权威 parser。 + +--- + +# 4. 目标架构 + +## 4.1 分层 + +```text +frontend/src/lib/completion/ +├── core/ +│ ├── types.ts +│ ├── engine.ts +│ ├── parser.ts +│ ├── resolver.ts +│ ├── ranking.ts +│ ├── edit.ts +│ └── scheduler.ts +│ +├── fig/ +│ ├── types.ts +│ ├── adapter.ts +│ ├── specLoader.ts +│ ├── generatorRunner.ts +│ ├── compatibility.ts +│ └── manifest.ts +│ +├── host/ +│ ├── protocol.ts +│ ├── hostClient.ts +│ └── providers.ts +│ +├── targets/ +│ ├── local.ts +│ ├── ssh.ts +│ ├── wsl.ts +│ └── target.ts +│ +├── worker/ +│ └── completion.worker.ts +│ +└── legacy/ + └── legacySpecAdapter.ts +``` + +Rust: + +```text +backend/src/completion/ +├── mod.rs +├── protocol.rs +├── target.rs +├── executor.rs +├── local.rs +├── ssh.rs +├── wsl.rs +├── filesystem.rs +├── security.rs +└── tests.rs +``` + +App.vue 不再直接理解 Fig parser。 + +最终只保留: + +```text +App.vue + -> CompletionController + -> Worker + -> CompletionEngine +``` + +--- + +# 5. 核心类型设计 + +## 5.1 Edit Buffer + +现有: + +```ts +let pendingTerminalInput = ""; +``` + +迁移为: + +```ts +export interface EditBufferState { + sessionId: string; + revision: number; + + text: string; + cursor: number; // UTF-16 index into text + + cwd: string | null; + shell: ShellKind; + target: CompletionTarget; + + prompt?: { + text: string; + startColumn?: number; + }; +} + +type ShellKind = + | "bash" + | "zsh" + | "fish" + | "pwsh" + | "powershell" + | "cmd" + | "unknown"; +``` + +### 关键点 + +`revision` 是必须字段。 + +任何 async generator 回来时,都必须检查: + +```ts +response.revision === current.revision +``` + +否则直接丢弃。 + +这可以一次性解决: + +- SSH RTT 导致的 stale completion; +- generator 慢响应覆盖新输入; +- 快速连续 Tab; +- session 切换后旧结果串入新 terminal。 + +--- + +## 5.2 CompletionItem + +不要再让候选只保存 `token`。 + +最终统一为: + +```ts +export interface CompletionItem { + id: string; + + label: string; + description?: string; + icon?: string; + kind: + | "command" + | "subcommand" + | "option" + | "argument" + | "file" + | "directory" + | "history" + | "snippet"; + + score: number; + source: string; + + edit: CompletionEdit; +} + +export interface CompletionEdit { + text: string; + replaceStart: number; + replaceEnd: number; + cursorOffset?: number; +} +``` + +这样可以彻底避免: + +```text +--output=json +``` + +被错误替换成: + +```text +--output=--output=json +``` + +也避免后续 generator 返回: + +```text +/path/file.txt +``` + +时再靠 `/\S+$/` 猜范围。 + +**编辑操作必须由 parser / resolver 产生,UI 只能执行。** + +--- + +# 6. Fig Spec 适配策略 + +## 6.1 不修改 upstream spec + +不要把 Fig spec 转换成人工维护的: + +```ts +CompletionFlagSpec +CompletionPositionalSpec +``` + +不要再手工维护 12 个命令的裁剪版本作为长期主源。 + +正确方式是: + +```text +upstream Fig Spec + ↓ +Fig Runtime Types + ↓ +CompletionEngine + ↓ +CompletionItem +``` + +只有 UI adapter 才把它转成 `CompletionRow`。 + +--- + +## 6.2 Spec Snapshot + +新增: + +```text +frontend/vendor/fig-specs/ +``` + +不要把整个 git 仓库当作项目运行时依赖。 + +保存: + +```json +{ + "source": "withfig/autocomplete", + "commit": "", + "generatedAt": "", + "formatVersion": 1 +} +``` + +实际文件: + +```text +frontend/vendor/fig-specs/build/ +├── git.js +├── docker.js +├── kubectl.js +├── helm.js +├── aws.js +├── npm.js +├── pnpm.js +└── ... +``` + +并生成: + +```ts +export interface FigSpecManifestEntry { + name: string; + module: () => Promise; +} + +export const FIG_SPEC_MANIFEST: Record = ...; +``` + +### 为什么要按 command 拆 chunk + +如果一次把全部 spec 打进主 bundle,会直接增加 WebView 首屏 JS 负担。 + +目标是: + +```text +输入 git + -> 只动态加载 git spec + +输入 kubectl + -> 只动态加载 kubectl spec +``` + +推荐 Vite: + +```ts +const loaders = import.meta.glob( + "/src/vendor/fig-specs/build/*.js", + { eager: false } +); +``` + +--- + +# 7. Spec 同步脚本 + +新增: + +```text +scripts/sync_fig_specs.mjs +scripts/verify_fig_specs.mjs +frontend/src/lib/completion/fig/spec-manifest.generated.ts +``` + +`package.json`: + +```json +{ + "scripts": { + "fig:sync": "node scripts/sync_fig_specs.mjs", + "fig:verify": "node scripts/verify_fig_specs.mjs", + "fig:test": "vitest run src/lib/completion" + } +} +``` + +同步流程: + +```text +1. checkout pinned withfig/autocomplete commit +2. 安装 build-time Node dependencies +3. 编译 spec TS +4. 构建 spec manifest +5. 写入 snapshot metadata +6. 运行 compatibility scan +7. 运行 parser fixture tests +``` + +**运行时不需要 Node。** + +Node 只存在于 build / CI 环节。 + +--- + +# 8. Parser 集成方式 + +## 8.1 推荐方案 + +直接 vendor / fork 当前 Amazon Q autocomplete parser 的 TypeScript 实现,并删除不需要的 AWS 服务依赖。 + +不要在 Rust 中重新写 Fig parser。 + +目录: + +```text +frontend/src/lib/completion/fig/parser/ +``` + +或: + +```text +frontend/vendor/amazon-q-autocomplete-parser/ +``` + +最终由: + +```ts +CompletionEngine.resolve(buffer, spec) +``` + +统一调用。 + +--- + +## 8.2 保留 upstream parser 的语义边界 + +必须优先覆盖: + +```text +aliases +persistent options +repeatable options +variadic args +-- terminator +nested subcommands +option value parsing +exclusive / dependsOn +generator +loadSpec +generateSpec +``` + +不能把 Fig parser 再简化成: + +```text +command -> flag -> values +``` + +否则迁移到大量 upstream spec 后,问题会从“候选少”变成“命令行语义错误”。 + +--- + +# 9. Completion Worker + +新建: + +```text +frontend/src/lib/completion/worker/completion.worker.ts +``` + +消息: + +```ts +export interface CompletionRequest { + requestId: number; + revision: number; + trigger: "typing" | "tab" | "manual"; + buffer: EditBufferState; +} + +export interface CompletionResponse { + requestId: number; + revision: number; + state: + | "idle" + | "loading" + | "ready" + | "pass-through" + | "error"; + + context?: CompletionContext; + items: CompletionItem[]; +} + +export interface CompletionContext { + command: string | null; + commandPath: string[]; + tokenStart: number; + tokenEnd: number; +} +``` + +### Worker 的职责 + +Worker 负责: + +1. 读取 spec; +2. parser; +3. resolver; +4. static candidates; +5. generator schedule; +6. provider merge; +7. ranking; +8. edit range; +9. 最终 `CompletionItem[]`。 + +Worker **不直接碰 DOM**。 + +--- + +# 10. CompletionHost:Rust 与 TypeScript 的唯一边界 + +定义: + +```ts +export interface CompletionHost { + execute(request: ExecuteCommandRequest): Promise; + listDirectory(request: ListDirectoryRequest): Promise; + getEnvironment(target: CompletionTarget): Promise; +} +``` + +目标类型: + +```ts +export type CompletionTarget = + | { + kind: "local"; + sessionId: string; + } + | { + kind: "ssh"; + sessionId: string; + } + | { + kind: "wsl"; + sessionId: string; + distro?: string; + }; +``` + +执行请求: + +```ts +export interface ExecuteCommandRequest { + target: CompletionTarget; + command: string; + args: string[]; + cwd?: string | null; + + timeoutMs: number; + maxOutputBytes: number; + + mode: "completion-generator"; +} +``` + +结果: + +```ts +export interface ExecuteCommandResult { + exitCode: number | null; + stdout: string; + stderr: string; + truncated: boolean; +} +``` + +--- + +# 11. Rust RPC 设计 + +建议增加一个专门的 completion RPC,不直接复用 UI 现有 generic `ssh/exec`。 + +新增: + +```text +completion/execute +completion/listDirectory +completion/environment +``` + +原因: + +1. 可以对 completion generator 单独限时; +2. 可以限制最大输出; +3. 可以单独做安全策略; +4. 不让普通用户 RPC 与 generator RPC 耦合; +5. 本地和 SSH 可以共用同一协议。 + +### `backend/src/completion/protocol.rs` + +```rust +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompletionExecuteRequest { + pub target: CompletionTarget, + pub command: String, + pub args: Vec, + pub cwd: Option, + pub timeout_ms: u64, + pub max_output_bytes: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum CompletionTarget { + Local { session_id: String }, + Ssh { session_id: String }, + Wsl { session_id: String, distro: Option }, +} +``` + +### `backend/src/completion/executor.rs` + +```rust +#[async_trait::async_trait] +pub trait CompletionExecutor: Send + Sync { + async fn execute( + &self, + request: CompletionExecuteRequest, + ) -> Result; + + async fn list_directory( + &self, + request: ListDirectoryRequest, + ) -> Result; +} +``` + +--- + +# 12. SSH executor + +已有: + +```text +ssh/exec +``` + +已有 `SshRuntime::exec` 能力。 + +completion SSH executor 不要复制 SSH 连接池,而是: + +```text +CompletionExecuteRequest + ↓ +CompletionSshExecutor + ↓ +existing SshRuntime::exec + ↓ +existing sessionId +``` + +但是必须加: + +```text +max timeout +max output +read-only completion marker +``` + +建议内部调用统一走: + +```text +ssh.exec(session_id, exec_id, command, sudo=false, timeout) +``` + +并让 completion executor 自己裁切 stdout / stderr。 + +**generator 禁止走 sudo。** + +--- + +# 13. Local executor + +已有: + +```text +backend/src/local_terminal.rs +``` + +本地终端已经由 portable-pty 管理。 + +但 generator 不应该往现有交互 PTY 里注入命令。 + +必须独立创建短生命周期 command process: + +```text +local completion generator + -> CommandBuilder + -> stdout pipe + -> stderr pipe + -> timeout + -> kill on timeout +``` + +原因: + +如果直接把: + +```text +printf ... +``` + +注入用户正在使用的 shell,generator 输出会污染终端状态。 + +--- + +# 14. WSL executor + +Windows 本机 target: + +```text +local +``` + +Windows 上的 Linux WSL target: + +```text +wsl +``` + +第一版直接: + +```text +wsl.exe -d -- +``` + +但注意: + +- `cwd` 要转成 WSL 路径; +- Windows 路径不能直接送给 Linux command; +- `/mnt/c/...` 与 `C:\...` 要做显式映射; +- WSL 不应该通过用户默认交互 shell 执行 generator。 + +--- + +# 15. Generator 分三级实现 + +这是整个项目最关键的分阶段点。 + +## Level 1:静态 Spec + +支持: + +- command; +- subcommand; +- option; +- args; +- aliases; +- static suggestions; +- description; +- priority。 + +这是第一批必须完成的兼容层。 + +--- + +## Level 2:Declarative Generator + +支持: + +```ts +generators: { + script: ["git", "branch", "--format=%(refname:short)"], + postProcess: ... +} +``` + +Fig spec 已大量使用这种形式,例如 CF、Watson 等 spec 会运行目标 CLI,再对 stdout 做解析。 + +这里最重要的是: + +```text +script 定义 + ↓ +CompletionHost.execute() + ↓ +target machine +``` + +而不是: + +```text +browser + ↓ +local desktop shell +``` + +--- + +## Level 3:Custom JS Generator + +最后再支持: + +```ts +generators: async (context) => { ... } +``` + +以及: + +```text +generateSpec +loadSpec +custom generator +``` + +### 推荐运行位置 + +第一版放在 Worker,但必须提供 compatibility shim。 + +不能假设浏览器环境拥有: + +```text +process +fs +child_process +path +os +fetch +``` + +因此要给 generator 一个 host facade: + +```ts +const host = { + execute, + listDirectory, + environment, +}; +``` + +### 不建议 + +不要在 v1 把完整 Node runtime 嵌进 Rust。 + +原因: + +- module resolution; +- Node builtin; +- package dependency; +- sandbox; +- memory; +- Windows runtime; +- package size; +- CVE surface。 + +--- + +# 16. Generator 安全策略 + +这一项不能省。 + +Fig generator 本质上允许 spec 声明命令执行。 + +例如: + +```ts +generators: { + script: ["git", "branch"] +} +``` + +在 SSH 场景,这意味着: + +```text +autocomplete + -> remote git branch +``` + +如果 spec 被篡改,就可能变成任意命令执行。 + +## 默认策略 + +```ts +export interface CompletionExecutionPolicy { + enabled: boolean; + maxRuntimeMs: number; + maxOutputBytes: number; + allowShellScript: boolean; + allowNetwork: boolean; +} +``` + +默认: + +```text +enabled = true +maxRuntimeMs = 1200 +maxOutputBytes = 256 KiB +allowShellScript = false +allowNetwork = false +``` + +因此: + +```text +["git", "branch"] +``` + +可以执行。 + +但: + +```text +["bash", "-c", "git branch | grep ..."] +``` + +在默认安全模式下应降级为: + +```text +pass-through / no dynamic result +``` + +后续可以增加: + +```text +Settings -> Trust upstream completion generators +``` + +但不要默认开启任意 shell script generator。 + +--- + +# 17. 文件补全 + +Fig generator 并不应该承担所有 filesystem completion。 + +项目已有: + +```text +SFTP +local/fs/browse +remote directory tracking +``` + +因此文件补全建议独立 provider: + +```ts +export interface FileCompletionProvider { + complete(request: FileCompletionRequest): Promise; +} +``` + +映射: + +```text +Local target -> local filesystem +SSH target -> SFTP +WSL target -> WSL filesystem +``` + +这样: + +```text +git add src/ +``` + +不需要执行: + +```text +find . +``` + +而是直接从文件 provider 返回候选。 + +--- + +# 18. Git / kubectl / docker 等动态候选 + +优先使用 Fig 的 declarative generator: + +```text +git branch +kubectl get pods +helm list +``` + +但执行时经过: + +```text +CompletionHost +``` + +示例: + +```text +git checkout ma + +parse + commandPath = ["git", "checkout"] + arg = branch + prefix = "ma" + +spec generator + script = ["git", "branch", "--format=..."] + +host + target = ssh(session-123) + +remote execution + git branch ... + +postProcess + master + main + maintenance + +merge + -> CompletionItem[] +``` + +--- + +# 19. CompletionController + +新增: + +```text +frontend/src/lib/completion/CompletionController.ts +``` + +API: + +```ts +export interface CompletionController { + updateBuffer(buffer: EditBufferState): void; + request(trigger: CompletionTrigger): void; + accept(item: CompletionItem): void; + move(delta: number): void; + dismiss(): void; +} +``` + +App.vue 只负责: + +```ts +const completion = new CompletionController(...); +``` + +不再直接写: + +```ts +matchSpecLine(...) +openCompletionMenu(...) +fetchDynamicCompletionRows(...) +``` + +这样以后 UI 换成 command palette、floating panel、inline hint 都无需改 parser。 + +--- + +# 20. xterm.js 输入链路改造 + +当前链路: + +```text +xterm.onData + -> sendTerminalBytes + -> trackPendingInput + -> refreshSuggestionsAfterInput +``` + +改成: + +```text +xterm.onData + -> updateEditBuffer(data) + -> revision++ + -> sendTerminalBytes(data) + -> completion.request("typing") +``` + +### 注意顺序 + +必须先更新 completion state,再发 async request。 + +同时保存: + +```text +requestId +revision +sessionId +``` + +返回时三项都必须匹配。 + +--- + +# 21. 键盘消费语义 + +当前分支的 Enter 透传、动态 hint Tab 透传语义必须保留。 + +最终规则: + +| 状态 | Enter | Tab | ↑↓ | Esc | +|---|---|---|---|---| +| passive completion | shell | shell / explicit enter-completion | menu | close | +| interactive completion | shell 默认;只有显式 accept 模式才消费 | accept | move | close | +| pass-through | shell | shell | shell | close | +| ghost only | shell | shell | shell | close | + +### 最关键 + +```text +completionOpen !== keyboardOwnership +``` + +菜单显示,不代表菜单拥有 Enter / Tab。 + +这条规则必须写成单元测试,不允许回归。 + +--- + +# 22. Completion Accept:统一 Edit Operation + +当前已有 `replaceStart / replaceEnd`,保留并升级为: + +```ts +applyCompletionEdit(edit: CompletionEdit) +``` + +伪代码: + +```ts +function applyCompletionEdit(edit: CompletionEdit) { + const line = editBuffer.text; + + const next = + line.slice(0, edit.replaceStart) + + edit.text + + line.slice(edit.replaceEnd); + + const nextCursor = + edit.replaceStart + + (edit.cursorOffset ?? edit.text.length); + + writeTerminalEdit(line, next, editBuffer.cursor, nextCursor); +} +``` + +### v1 限制 + +当前项目的 PTY line editor 仍是 shell 自己管理。 + +第一阶段可继续将 completion accept 限定在: + +```text +cursor == logicalLineEnd +``` + +后续再增加任意 cursor position。 + +--- + +# 23. 任意光标位置支持路线 + +要最终支持: + +```text +git sta --oneline +``` + +而光标位于 `sta` 中间,需要: + +1. `EditBufferState.cursor`; +2. shell cursor movement tracking; +3. xterm buffer cursor position; +4. prompt start offset; +5. wrapped line mapping; +6. wide char / surrogate pair mapping。 + +推荐先实现: + +```text +line-end completion +``` + +再实现: + +```text +in-line completion +``` + +不要在第一阶段同时解决 shell cursor reconstruction。 + +--- + +# 24. Overlay 与 Completion Engine 解耦 + +当前 `CompletionMenu.vue` 已经达到目标 UI 基础设施,迁移时不要重写。 + +只需要把: + +```ts +CompletionRow[] +``` + +改成: + +```ts +CompletionItem[] +``` + +然后: + +```text +label + -> label + +description + -> description + +kind + -> kind + +accept + -> item.edit +``` + +现有: + +```text +overlayPlacement.ts +terminalAnchor.ts +``` + +继续复用。 + +--- + +# 25. Dynamic Provider 迁移 + +当前: + +```text +frontend/src/lib/completions/provider.ts +``` + +当前 provider: + +```ts +complete(): Promise +``` + +应升级成: + +```ts +interface CompletionProvider { + id: string; + + matches(context: CompletionContext): boolean; + + complete( + request: CompletionProviderRequest, + ): Promise; +} +``` + +这样 provider 可以返回: + +- description; +- icon; +- kind; +- score; +- edit range; +- source。 + +--- + +# 26. Target 抽象 + +不要使用: + +```ts +isLocalMode +``` + +作为 completion 业务核心判断。 + +新增: + +```ts +interface CompletionTargetInfo { + kind: "local" | "ssh" | "wsl"; + sessionId: string; + + os: "macos" | "linux" | "windows" | "wsl"; + shell: ShellKind; + cwd: string | null; +} +``` + +这样: + +```text +UI Desktop OS + ≠ +Completion Target OS +``` + +### 示例 + +| Desktop | Target | Generator 执行地 | +|---|---|---| +| macOS | local zsh | macOS | +| macOS | SSH Ubuntu | Ubuntu | +| macOS | SSH Windows | Windows | +| Windows | local PowerShell | Windows | +| Windows | WSL Ubuntu | WSL Ubuntu | +| Windows | SSH Ubuntu | Ubuntu | +| Linux | SSH macOS | macOS | +| Linux | local bash | Linux | + +--- + +# 27. Multi-platform 行为 + +## macOS + +支持: + +- zsh; +- bash; +- fish; +- SSH Linux / macOS / Windows。 + +不需要 Accessibility API,因为 overlay 已经是 xterm 容器内部 DOM。 + +--- + +## Linux + +支持: + +- bash; +- zsh; +- fish; +- SSH Linux / macOS / Windows。 + +--- + +## Windows + +支持: + +- PowerShell; +- pwsh; +- cmd; +- WSL; +- SSH Linux / macOS / Windows。 + +Completion engine 本身不需要平台 if/else。 + +平台差异全部在: + +```text +CompletionHost +``` + +--- + +# 28. Shell 类型不要决定 Parser 类型 + +Parser 主要处理 CLI 语义。 + +Shell 差异主要体现在: + +```text +quoting +escaping +path separators +environment +command invocation +``` + +因此: + +```ts +parseCommandLine(text) +``` + +不能绑定 bash。 + +建议 parser context: + +```ts +interface ShellParseContext { + shell: ShellKind; + platform: TargetPlatform; +} +``` + +但默认保持 shell-neutral。 + +--- + +# 29. Spec Cache + +spec 应放到两级 cache: + +```text +L1 Worker memory +L2 bundled/dynamic imported ESM +``` + +不要每个按键都重新加载文件。 + +每个 command spec: + +```ts +Map> +``` + +generator 候选单独缓存: + +```text +(command, context, cwd, target, prefix) +``` + +TTL 建议从 300ms 起。 + +例如: + +```text +git branch +kubectl get pods +``` + +快速连续输入时避免重复执行远端命令。 + +--- + +# 30. Request Cancellation + +每次输入都可能产生 generator: + +```text +git chec + git checko + git checkout +``` + +如果三个 generator 全部打远端,浪费 RTT。 + +因此增加: + +```ts +AbortSignal +``` + +流程: + +```text +request N + ↓ +request N+1 + ↓ +abort N +``` + +Rust sidecar 侧: + +- 已启动的 local child 进程必须 kill; +- SSH exec 使用已有 exec cancellation; +- timeout 后立即回收。 + +--- + +# 31. Generator Scheduler + +不要: + +```ts +await Promise.all(allGenerators) +``` + +第一版推荐: + +```text +static results + ↓ immediately render + +async generators + ↓ pending state + +generator result + ↓ merge + reorder +``` + +即: + +```text +菜单先打开 + ↓ +显示静态候选 + ↓ +100~1000ms 内动态候选回来 + ↓ +更新菜单 +``` + +这样 SSH 高 RTT 不会让菜单整体等待。 + +--- + +# 32. Ranking + +统一评分层: + +```text +1. exact +2. prefix +3. priority +4. generator result +5. kind priority +6. lexical +``` + +不要把 ranking 分散在: + +- legacy spec; +- dynamic provider; +- history; +- UI。 + +所有候选进统一: + +```ts +rank(items, context) +``` + +--- + +# 33. History / Ghost / Fig 三路统一 + +当前存在三套候选: + +```text +history suggestion +structured spec completion +ghost suggestion +``` + +最终应明确为: + +```text +Completion Source +├── Spec +├── Generator +├── FileSystem +├── History +└── ShellFallback +``` + +但 UI 保持两种展示: + +```text +interactive dropdown +inline ghost +``` + +### 关系 + +```text +Spec / Generator / File / History + ↓ + CompletionEngine + ↓ + dropdown candidate list + +History + ↓ + Ghost Engine + ↓ + inline remainder +``` + +不要让 ghost 和 Fig parser 相互调用。 + +--- + +# 34. Shell fallback + +当: + +```text +无 spec +或 +spec 无法解析当前 context +或 +generator 不允许执行 +``` + +应进入: + +```text +pass-through +``` + +不要制造假的静态 hint。 + +例如: + +```text +some-internal-cli +``` + +没有 spec 时: + +```text +CompletionEngine -> pass-through +``` + +Tab 继续交给真实 shell completion。 + +这是现有动态 hint 透传策略的升级版。 + +--- + +# 35. Legacy Spec 迁移 + +现有: + +```text +frontend/src/lib/completions/spec.ts +frontend/src/lib/completions/specs/*.ts +``` + +不要一次删除。 + +先做: + +```text +LegacyCompletionProvider +FigCompletionProvider +``` + +resolver: + +```ts +const providers = [ + figProvider, + legacyProvider, +]; +``` + +优先: + +```text +Fig > Legacy +``` + +如果 Fig spec 不存在,再走 legacy。 + +--- + +# 36. 迁移顺序 + +## M1:Completion Core + +新增: + +```text +CompletionItem +CompletionEdit +EditBufferState +CompletionRequest +CompletionResponse +``` + +并把现有 `SpecMatch` 转成 adapter。 + +验收:现有 12 个本地 spec 的行为零回归。 + +--- + +## M2:Worker + +新增: + +```text +completion.worker.ts +CompletionController +``` + +把 parser 从 App.vue 移出去。 + +验收: + +```text +git ch +git checkout - +kubectl get -o +``` + +与当前 UI 行为一致。 + +--- + +## M3:Fig Spec Bundle + +新增 build pipeline: + +```text +withfig/autocomplete snapshot + ↓ +compiled spec modules + ↓ +manifest +``` + +第一阶段只开放: + +```text +git +kubectl +helm +docker +npm +pnpm +yarn +ssh +aws +cargo +systemctl +``` + +然后逐步扩充全部 spec。 + +--- + +## M4:CompletionHost + +Rust 新增: + +```text +completion/execute +completion/listDirectory +completion/environment +``` + +前端新增: + +```text +HostClient +LocalTarget +SshTarget +``` + +验收: + +```text +local git branch +SSH git branch +``` + +两者都从目标机器得到候选。 + +--- + +## M5:Declarative Generators + +支持: + +```text +script +postProcess +splitOn +``` + +至少覆盖: + +```text +git branch +kubectl pods +helm releases +``` + +验收: + +```text +git checkout +kubectl delete pod +helm uninstall +``` + +SSH 场景必须执行到远端。 + +--- + +## M6:File Provider + +支持: + +```text +local +SSH/SFTP +WSL +``` + +验收: + +```text +git add +cat /etc/ +vim ./src/ +``` + +--- + +## M7:Custom Generator Compatibility + +支持: + +```text +custom +loadSpec +``` + +并引入 capability facade。 + +不支持的 Node API 返回: + +```text +unsupported -> generator ignored -> fallback +``` + +不能因此让 completion engine 崩溃。 + +--- + +# 37. Rust 文件级任务清单 + +```text +backend/src/completion/mod.rs +``` + +注册 completion 子模块。 + +```text +backend/src/completion/protocol.rs +``` + +定义 JSON request / response。 + +```text +backend/src/completion/target.rs +``` + +定义: + +- Local; +- SSH; +- WSL。 + +```text +backend/src/completion/executor.rs +``` + +统一 executor trait。 + +```text +backend/src/completion/local.rs +``` + +短命令进程 + timeout + output cap。 + +```text +backend/src/completion/ssh.rs +``` + +复用现有 `SshRuntime::exec`。 + +```text +backend/src/completion/wsl.rs +``` + +WSL command adapter。 + +```text +backend/src/completion/filesystem.rs +``` + +统一目录候选接口。 + +```text +backend/src/completion/security.rs +``` + +generator execution policy。 + +然后在: + +```text +backend/src/main.rs +``` + +添加: + +```text +completion/execute +completion/listDirectory +completion/environment +``` + +--- + +# 38. Frontend 文件级任务清单 + +保留并演进: + +```text +frontend/src/lib/completions/spec.ts +frontend/src/lib/completions/provider.ts +frontend/src/lib/overlayPlacement.ts +frontend/src/lib/terminalAnchor.ts +frontend/src/components/CompletionMenu.vue +``` + +新增: + +```text +frontend/src/lib/completion/core/types.ts +frontend/src/lib/completion/core/engine.ts +frontend/src/lib/completion/core/parser.ts +frontend/src/lib/completion/core/resolver.ts +frontend/src/lib/completion/core/ranking.ts +frontend/src/lib/completion/core/edit.ts +frontend/src/lib/completion/core/scheduler.ts + +frontend/src/lib/completion/fig/adapter.ts +frontend/src/lib/completion/fig/specLoader.ts +frontend/src/lib/completion/fig/generatorRunner.ts +frontend/src/lib/completion/fig/compatibility.ts + +frontend/src/lib/completion/host/protocol.ts +frontend/src/lib/completion/host/hostClient.ts + +frontend/src/lib/completion/targets/local.ts +frontend/src/lib/completion/targets/ssh.ts +frontend/src/lib/completion/targets/wsl.ts + +frontend/src/lib/completion/worker/completion.worker.ts +frontend/src/lib/completion/CompletionController.ts +``` + +--- + +# 39. App.vue 最终改造目标 + +当前: + +```ts +matchSpecLine(...) +openCompletionMenu(...) +fetchDynamicCompletionRows(...) +acceptCompletionRow(...) +``` + +迁移后: + +```ts +completionController.updateBuffer(editBuffer); +completionController.request("typing"); +``` + +收到: + +```ts +completionController.onResponse((response) => { + completionItems.value = response.items; +}); +``` + +接受: + +```ts +completionController.accept(activeItem); +``` + +App.vue 不再知道: + +- Fig parser; +- generator; +- spec shape; +- remote command; +- postProcess。 + +--- + +# 40. CompletionMenu.vue 最终改造目标 + +Props: + +```ts +rows: CompletionItem[]; +activeIndex: number; +anchor: SuggestionAnchor | null; +viewport?: { height: number }; +``` + +Event: + +```ts +activate(index) +accept(item) +``` + +它只关心: + +```text +render +highlight +position +emit accept +``` + +不要把 parser logic 放回组件。 + +--- + +# 41. 测试设计 + +## 41.1 Parser Fixture + +建立: + +```text +frontend/src/lib/completion/fixtures/ +``` + +每个 fixture: + +```json +{ + "input": "git checkout -", + "command": "git", + "path": ["git", "checkout"], + "level": "option" +} +``` + +至少覆盖: + +```text +alias +short option +long option +--flag=value +quoted arg +escaped arg +-- +variadic arg +repeatable option +nested subcommand +``` + +--- + +## 41.2 Generator Tests + +所有 generator 使用 fake host: + +```ts +const host = new FakeCompletionHost({ + execute: async () => ({ + stdout: "main\nmaster\n", + }), +}); +``` + +禁止单元测试真的运行本机 shell。 + +--- + +## 41.3 SSH Integration Test + +使用当前项目已有 docker / smoke infrastructure。 + +测试: + +```text +SSH Ubuntu + git checkout + -> remote branch list +``` + +验证: + +```text +local command count == 0 +remote command count == 1+ +``` + +--- + +## 41.4 Windows + +必须覆盖: + +```text +Windows local PowerShell +Windows local cmd +Windows WSL Ubuntu +Windows SSH Linux +``` + +特别检查: + +```text +path separator +cwd mapping +UTF-16 replacement +ConPTY cursor +``` + +--- + +## 41.5 UI Regression + +保留现有 issue #120: + +```text +light theme + dark theme +below +above +insufficient space +left clamp +host bottom +SSH RTT anchor resettle +``` + +并新增: + +```text +static -> dynamic update +stale generator response +Tab pass-through +Enter pass-through +session switch stale response +``` + +--- + +# 42. 性能目标 + +不是以“每次按键一次远端 RPC”为目标。 + +目标链路: + +```text +typing + ↓ +static result < immediate + ↓ +generator async + ↓ +merge +``` + +要求: + +- 静态 spec 不经过 RPC; +- 同一 request 只跑一次 generator; +- 旧 revision 立即取消; +- generator output 必须上限; +- 结果必须上限 20~50 条 UI item; +- spec 动态 import 只发生一次。 + +--- + +# 43. 失败降级策略 + +任何一层失败都必须 fallback,而不是打断 terminal: + +```text +spec load fail + -> history / shell fallback + +generator timeout + -> static candidates + +generator unsupported + -> static candidates + +RPC fail + -> static candidates / shell fallback + +stale result + -> drop silently + +bad spec + -> blacklist command spec + +worker crash + -> restart worker +``` + +**补全绝对不能影响 PTY 输入链路。** + +--- + +# 44. Worker Crash Recovery + +`CompletionController` 必须拥有: + +```ts +restartWorker(): void +``` + +当 Worker: + +```text +messageerror +error +``` + +时: + +```text +1. drop current completion +2. restart worker +3. keep terminal working +``` + +不要因为 completion worker 崩溃导致 terminal 页面失去输入。 + +--- + +# 45. Feature Flag + +第一阶段增加: + +```text +ssh-completion-engine +``` + +值: + +```text +legacy +fig +fig-safe +``` + +推荐默认顺序: + +```text +fig-safe +``` + +其中: + +```text +fig-safe + = static specs + safe declarative generator + filesystem provider +``` + +`fig`: + +```text +full declarative + custom generator +``` + +仅作为实验开关。 + +--- + +# 46. 设置项 + +建议: + +```text +Settings -> Terminal -> Command Completion + +[✓] Structured completion +[✓] Remote dynamic completion +[✓] File completion +[ ] Trusted shell-script generators +``` + +默认: + +```text +Structured completion = ON +Remote dynamic completion = ON +File completion = ON +Trusted shell-script generators = OFF +``` + +--- + +# 47. Spec 更新策略 + +不要启动时联网下载最新 spec。 + +采用: + +```text +CI + ↓ +pinned spec commit + ↓ +bundle + ↓ +release artifact +``` + +每次 release 带: + +```text +completionSpecSource +completionSpecCommit +completionEngineVersion +``` + +例如: + +```json +{ + "engine": "dbx-fig-v1", + "specSource": "withfig/autocomplete", + "specCommit": "abc123..." +} +``` + +这样用户问题可以精确复现。 + +--- + +# 48. License / Attribution + +必须随 vendor 一起保留 upstream license / notice。 + +推荐: + +```text +frontend/vendor/NOTICE.fig.txt +frontend/vendor/LICENSE.fig.txt +frontend/vendor/NOTICE.amazon-q-autocomplete.txt +``` + +并在项目 `NOTICE` / about 页面说明: + +```text +Completion specifications are derived from the public Fig/Amazon Q +autocomplete ecosystem and are not part of the dbx-plugin-ssh original +specification set. +``` + +实际采用哪个上游 parser snapshot 时,再按该 snapshot 的 LICENSE / NOTICE 精确落盘。 + +--- + +# 49. 不应做的事情 + +## 不要 1:把 Fig UI 整个搬进来 + +你现在已经有 Vue + xterm.js + terminal overlay。 + +搬 React UI 会导致: + +- 双框架; +- CSS 隔离; +- keyboard ownership; +- focus; +- theme token; +- overlay 定位重复。 + +没有收益。 + +## 不要 2:在 Rust 重写 Fig parser + +会形成: + +```text +Fig parser semantics + ≠ +Rust parser semantics +``` + +最终 upstream spec 越多,兼容性越差。 + +## 不要 3:generator 在桌面机执行 + +SSH target 一定要 target-side execution。 + +## 不要 4:补全直接写用户交互 PTY + +generator 必须使用独立 command execution。 + +## 不要 5:保留 `row.token` 作为唯一 accept 信息 + +必须升级成 `CompletionEdit`。 + +## 不要 6:用 `completionOpen` 判断键盘所有权 + +菜单可见与键盘消费是两个状态。 + +--- + +# 50. 最终目录结构 + +```text +dbx-plugin-ssh/ +├── backend/ +│ └── src/ +│ ├── completion/ +│ │ ├── mod.rs +│ │ ├── protocol.rs +│ │ ├── target.rs +│ │ ├── executor.rs +│ │ ├── local.rs +│ │ ├── ssh.rs +│ │ ├── wsl.rs +│ │ ├── filesystem.rs +│ │ └── security.rs +│ └── main.rs +│ +├── frontend/ +│ ├── src/ +│ │ ├── components/ +│ │ │ └── CompletionMenu.vue +│ │ └── lib/ +│ │ ├── completions/ +│ │ │ ├── spec.ts +│ │ │ ├── provider.ts +│ │ │ └── specs/ +│ │ └── completion/ +│ │ ├── core/ +│ │ ├── fig/ +│ │ ├── host/ +│ │ ├── targets/ +│ │ ├── worker/ +│ │ └── CompletionController.ts +│ │ +│ └── vendor/ +│ ├── fig-specs/ +│ └── amazon-q-autocomplete-parser/ +│ +├── scripts/ +│ ├── sync_fig_specs.mjs +│ └── verify_fig_specs.mjs +│ +└── docs/ + └── FIG_AUTOCOMPLETE_INTEGRATION_PLAN.zh-CN.md +``` + +--- + +# 51. 最终完成判定 + +## A. Static Spec + +```text +[ ] 100+ upstream specs 可加载 +[ ] alias 正确 +[ ] options 正确 +[ ] args 正确 +[ ] nested subcommands 正确 +[ ] -- 正确 +[ ] replacement range 正确 +``` + +## B. Generator + +```text +[ ] git dynamic +[ ] kubectl dynamic +[ ] helm dynamic +[ ] file provider +[ ] timeout +[ ] cancellation +[ ] stale response drop +``` + +## C. Target + +```text +[ ] local macOS +[ ] local Linux +[ ] local Windows +[ ] WSL +[ ] SSH Linux +[ ] SSH macOS +[ ] SSH Windows +``` + +## D. UI + +```text +[ ] light theme +[ ] dark theme +[ ] above +[ ] below +[ ] constrained height +[ ] right edge clamp +[ ] SSH RTT resettle +[ ] keyboard ownership tests +``` + +## E. Stability + +```text +[ ] completion failure never blocks PTY +[ ] worker crash recovery +[ ] generator process cleanup +[ ] remote timeout +[ ] session switch stale result cleanup +``` + +--- + +# 52. 推荐的实际落地顺序 + +严格按下面顺序做,避免一次把 parser / generator / target / UI 全部揉在一起: + +```text +Step 1 +CompletionItem + CompletionEdit + +Step 2 +EditBufferState + revision + +Step 3 +CompletionController + +Step 4 +Worker 化现有 spec parser + +Step 5 +Fig adapter + upstream parser snapshot + +Step 6 +Fig static specs + +Step 7 +CompletionHost RPC + +Step 8 +SSH / Local executor + +Step 9 +Declarative generators + +Step 10 +Filesystem provider + +Step 11 +WSL + +Step 12 +Custom JS generator + +Step 13 +全量 spec corpus + +Step 14 +Legacy spec 下线 +``` + +--- + +# 53. 对当前分支的最小改动起点 + +不建议现在再继续往 `frontend/src/lib/completions/spec.ts` 里堆 Fig 特性。 + +第一组真正应该开始写的文件是: + +```text +frontend/src/lib/completion/core/types.ts +frontend/src/lib/completion/core/edit.ts +frontend/src/lib/completion/CompletionController.ts +frontend/src/lib/completion/worker/completion.worker.ts +``` + +然后把现有: + +```ts +matchSpecLine(pendingTerminalInput, COMPLETION_SPECS) +``` + +封装进: + +```ts +LegacyCompletionProvider +``` + +这一步完成后,后面接 Fig parser 就只是新增 provider,不再需要继续改 App.vue 的键盘 / overlay / SSH 代码。 + +Rust 侧第一组文件: + +```text +backend/src/completion/mod.rs +backend/src/completion/protocol.rs +backend/src/completion/executor.rs +backend/src/completion/local.rs +backend/src/completion/ssh.rs +backend/src/main.rs +``` + +先跑通: + +```text +local git branch +SSH git branch +``` + +再接其他 generator。 + +--- + +# 54. CI 验收命令 + +保持当前项目既有门禁,再增加 completion 专项: + +```bash +pnpm --dir frontend typecheck +pnpm --dir frontend test +pnpm --dir frontend build + +pnpm --dir frontend fig:test +pnpm --dir frontend fig:verify + +cargo test --manifest-path backend/Cargo.toml +``` + +最终 smoke: + +```text +local terminal smoke +SSH terminal smoke +completion static smoke +completion dynamic smoke +Windows / WSL smoke +``` + +任何 completion failure 都不能导致: + +```text +PTY input failure +SSH terminal failure +local terminal failure +``` + +--- + +# 55. 实施后的最终责任边界 + +```text +Vue / App.vue + 只负责 UI + xterm + edit application + +CompletionController + 只负责生命周期 / revision / UI state + +Completion Worker + 只负责 Fig parser / resolver / ranking / generator scheduling + +CompletionHost + 只负责 target capabilities + +Rust Completion Executor + 只负责进程 / SSH / WSL / filesystem / timeout / security + +xterm.js + 只负责 terminal rendering / terminal input + +PTTY + 只负责真实 shell +``` + +这套边界一旦建立,未来增加: + +```text +Docker target +Kubernetes exec target +MCP target +Container target +Serial target +``` + +都只需要增加新的 `CompletionTarget` / `CompletionExecutor`,不需要再次改 Fig parser。 + +--- + +## 结论 + +针对当前 `dbx-plugin-ssh`,最合理的集成不是“把 Fig 搬进项目”,而是把 Fig/Amazon Q 的 **Spec + Parser + Generator model** 当成 completion engine,把你现有的 **Rust sidecar + xterm.js + SSH/local PTY** 当成 host runtime。 + +这样才能同时满足: + +```text +Fig spec 兼容 ++ SSH target-side generator ++ 本地 completion ++ Windows / Linux / macOS ++ WSL ++ 当前 xterm overlay ++ 当前 #120 replacement / positioning 修复 ++ 不影响 PTY 稳定性 +``` + +并且可以从当前分支以最小风险渐进迁移,而不是重新造一个终端补全系统。 diff --git a/docs/FIG_WAVE1_CONTRACT.zh-CN.md b/docs/FIG_WAVE1_CONTRACT.zh-CN.md new file mode 100644 index 00000000..8a0f107e --- /dev/null +++ b/docs/FIG_WAVE1_CONTRACT.zh-CN.md @@ -0,0 +1,98 @@ +# FIG 补全引擎 Wave 1 实施契约 + +> 协调者:主会话。基线分支:`codex/ssh/fig-wave1-base`(= main `0da8be89`,已含 +> fix-120 #120 系列)。方案全文:`docs/FIG_AUTOCOMPLETE_INTEGRATION_PLAN.zh-CN.md`。 +> 三个实施 lane 在各自 worktree/分支并发实施;分支合入由 integrator/用户决定, +> agent 不得自行 merge / push / 安装插件。 + +## 1. Wave 1 范围 + +| Lane | 分支 | 范围(对应方案里程碑) | +|---|---|---| +| A 前端核心 | `codex/ssh/fig-wave1-frontend-core` | M1 + M2-lite:core 纯函数(edit/ranking)、Legacy adapter、CompletionController、键盘所有权模块 + 单测、App.vue 接线、feature flag | +| B Rust Host | `codex/ssh/fig-wave1-completion-host` | M4-lite:`backend/src/completion/*`,仅 `completion/execute`(local + ssh target),PROTOCOL 文档,smoke 脚本 | +| C Fig 管线 | `codex/ssh/fig-wave1-fig-specs` | M3 地基:fig/types + adapter(静态子集)、sync/verify 脚本、体积实测报告、vendor LICENSE/NOTICE | + +## 2. 非目标(wave 2+,本波次禁止实施) + +- **Worker 化**:构建管线 `frontend/build.mjs` 用 `inlineDynamicImports: true` 产出 + 单文件自包含 index.html(并断言恰好 1 个 script 标签)。引擎 wave 1 跑主线程; + 接口保留 requestId/revision 字段为将来迁移留位。 +- **按命令懒加载 chunk**:同一构建约束下不可行;spec 全量进 bundle,体积是否 + 可接受由 Lane C 的实测报告定夺(wave 2 决策输入)。 +- WSL executor;declarative generator 与 UI 打通(等 B 合入后 wave 2 做 + `git checkout ` E2E);文件 provider 升级(沿用 fix-120 已有的 + `remoteFsProvider` 缓存版思路);custom JS generator / loadSpec。 + +## 3. 冻结契约 + +`frontend/src/lib/completion/core/types.ts` 与 `frontend/src/lib/completion/host/protocol.ts` +是本波次的冻结类型。实现方**只 import,不修改**;确需变更 → 写进 lane 报告, +由协调者统一裁决后再同步给所有 lane。 + +## 4. 总线决策 + +1. **revision 纪律**:一切异步结果(provider/generator)回来时必须校验 + `revision` 与 `sessionId` 都匹配当前 buffer,否则静默丢弃(方案 §5.1/§30/§43)。 +2. **键盘所有权**(方案 §21,必须以纯函数 + 单测固化,放 + `frontend/src/lib/completion/keyboard.ts`): + + | 状态 | Enter | Tab | ↑↓ | Esc | + |---|---|---|---|---| + | 菜单开 + 静态候选 | 放行 shell | accept | 移动 | 关闭 | + | 菜单开 + 动态 hint / loading | 放行 shell | 放行 shell | 移动 | 关闭 | + | 菜单关 | 放行 shell | 放行 shell | shell | — | + + 菜单显示 ≠ 键盘所有权;补全任何一层失败不得影响 PTY 输入链路。 +3. **feature flag**:pluginStore key `ssh-completion-engine`,值 + `"legacy" | "fig-safe"`,wave 1 默认 `"legacy"`(fig-safe 等 C 的 spec 落地后 + wave 2 再切默认)。SettingsDialog 增加引擎选择;新文案七语全补 + (zh-CN/zh-TW/en/es/it/ja/pt)。 +4. **accept 范围**:wave 1 仅行尾补全(`cursor === text.length`);任意光标位置 + 留 wave 2(方案 §22/§23)。 +5. **RPC 命名**:方法名 `completion/execute`;字段 camelCase(仓库协议约定, + 见 SKILL 与 `docs/PROTOCOL.zh-CN.md`);错误沿用 sidecar 字符串 Err 惯例, + 加 `completion:` 前缀分类。 + +## 5. completion/execute RPC 契约(与 host/protocol.ts 一致) + +约束: + +- generator 一律 `sudo=false`;只读连接(read_only)直接拒绝; +- `timeoutMs` 在 completion 层 clamp 到 [200, 3000],默认 1200; +- stdout/stderr 各自按 `maxOutputBytes` 截断并置 `truncated=true`; +- SSH 实现复用 `SshRuntime::exec`(`backend/src/ssh.rs` 约 :3638),带 exec_id; + completion 层竞速超时,超时后走既有 `ssh/exec/cancel` 同路径回收 + (**不修改** `SshRuntime::exec` 现有 `clamp(5,300)` 下限); +- local 实现用短生命周期子进程(`std::process::Command` + 超时杀进程), + **禁止**注入用户交互 PTY(`backend/src/local_terminal.rs` 的 PTY 与本功能无关); +- 审计与 `ssh/exec` 同模式(`backend/src/main.rs` 现有 audit 调用照搬); +- wave 1 不做 `completion/listDirectory`、`completion/environment`。 + +## 6. 文件归属(越界即冲突,禁止) + +| Lane | 拥有(新增/修改) | +|---|---| +| A | `frontend/src/lib/completion/core/{engine,edit,ranking}.ts`、`frontend/src/lib/completion/keyboard.ts`、`frontend/src/lib/completion/legacy/*`、`frontend/src/lib/completion/CompletionController.ts`;`frontend/src/App.vue`;`frontend/src/components/CompletionMenu.vue`(仅必要 props 适配);`frontend/src/lib/i18n.ts`;`frontend/src/components/SettingsDialog.vue`;对应 `*.spec.ts` | +| B | `backend/src/completion/*`;`backend/src/main.rs`(仅路由注册与 audit 接线);`docs/PROTOCOL.zh-CN.md`;`scripts/smoke_completion.py`;模块内 `#[cfg(test)]` | +| C | `frontend/src/lib/completion/fig/*`;`frontend/vendor/*`;`scripts/sync_fig_specs.mjs`;`scripts/verify_fig_specs.mjs`;`frontend/package.json`(仅 scripts 与必要 devDependencies);`docs/fig-specs-size-report.md`;对应 `*.spec.ts` | +| 只读共享 | `core/types.ts`、`host/protocol.ts`、`lib/completions/spec.ts`(legacy parser,A 经 adapter 包装、不改语义)、`lib/completions/provider.ts`、`lib/overlayPlacement.ts`、`backend/src/{ssh,exec,local_terminal}.rs`(B 只调用不重构) | + +## 7. 验证门禁(交付前必须全绿) + +环境:`export PATH="$HOME/.nvm/versions/node/v22.21.0/bin:$HOME/Library/pnpm:$HOME/.cargo/bin:$PATH"` + +- A/C:`pnpm --dir frontend install --prefer-offline` → `typecheck` → `test` → `build` +- B:`cargo fmt --manifest-path backend/Cargo.toml --check` → + `cargo clippy --locked --manifest-path backend/Cargo.toml --all-targets -- -D warnings` → + `cargo test --locked --manifest-path backend/Cargo.toml` +- 全 lane:不新增运行时依赖(SKILL 红线;C 的 devDependency 例外需在报告里论证); + 不使用真实 SSH 凭据;单测不得联网(C 的上游拉取只发生在显式 sync 命令,测试用 + fixture);不 push / 不 merge / 不安装插件。 + +## 8. 提交与移交 + +- 每 lane 在自己分支按逻辑单元提交(zh conventional commits,如 + `feat(completion): ...`);**不 push**。 +- lane 最终报告必须包含:base SHA、commit 列表、变更文件、验证结果摘要、 + 与契约的偏差、风险与 follow-up。 diff --git a/frontend/src/lib/completion/core/types.ts b/frontend/src/lib/completion/core/types.ts new file mode 100644 index 00000000..2653b3ba --- /dev/null +++ b/frontend/src/lib/completion/core/types.ts @@ -0,0 +1,82 @@ +// FIG 补全引擎 wave-1 契约类型(冻结)。 +// 实现方只 import,不修改;需要变更时写进 lane 报告由协调者裁决。 +// 依据 docs/FIG_AUTOCOMPLETE_INTEGRATION_PLAN.zh-CN.md §5/§9 与 +// docs/FIG_WAVE1_CONTRACT.zh-CN.md。wave-1 修正:引擎跑主线程(构建管线 +// 单文件内联),requestId/revision 字段保留为将来 Worker 化留位。 + +export type ShellKind = + | "bash" + | "zsh" + | "fish" + | "pwsh" + | "powershell" + | "cmd" + | "unknown"; + +export type CompletionTargetKind = "local" | "ssh" | "wsl"; + +export interface CompletionTarget { + kind: CompletionTargetKind; + sessionId: string; +} + +export interface EditBufferState { + sessionId: string; + /** 每次输入变更 +1;异步结果回来时 revision 不匹配即静默丢弃。 */ + revision: number; + /** 当前逻辑行文本(pendingTerminalInput 的升级形态)。 */ + text: string; + /** 光标在 text 中的 UTF-16 下标;wave 1 恒等于 text.length(行尾补全)。 */ + cursor: number; + target: CompletionTarget; + shell: ShellKind; +} + +export type CompletionItemKind = + | "command" + | "subcommand" + | "option" + | "argument" + | "file" + | "directory" + | "history" + | "hint"; + +/** 编辑操作由 parser/resolver 产生,UI 只执行(方案 §5.2)。 */ +export interface CompletionEdit { + text: string; + replaceStart: number; + replaceEnd: number; + cursorOffset?: number; +} + +export interface CompletionItem { + id: string; + label: string; + description?: string; + kind: CompletionItemKind; + score: number; + /** 候选来源标签(如 "legacy-spec" / "fig-spec" / "history")。 */ + source: string; + edit: CompletionEdit; +} + +export type CompletionTrigger = "typing" | "tab" | "manual"; + +export interface CompletionContext { + command: string | null; + commandPath: string[]; + /** 当前 token 在 text 中的 [start, end) UTF-16 下标。 */ + tokenStart: number; + tokenEnd: number; +} + +export type CompletionResponseState = "idle" | "loading" | "ready" | "pass-through"; + +export interface CompletionResponse { + requestId: number; + revision: number; + state: CompletionResponseState; + context?: CompletionContext; + items: CompletionItem[]; +} diff --git a/frontend/src/lib/completion/host/protocol.ts b/frontend/src/lib/completion/host/protocol.ts new file mode 100644 index 00000000..0b95a843 --- /dev/null +++ b/frontend/src/lib/completion/host/protocol.ts @@ -0,0 +1,29 @@ +// CompletionHost RPC 线协议(wave-1 冻结):前端 HostClient 与 Rust sidecar +// 的 completion/execute 共同遵守的唯一边界。字段一律 camelCase(仓库协议 +// 命名约定)。wave 1 只落 completion/execute;listDirectory / environment +// 留待 wave 2。Rust 侧结构体定义见 backend/src/completion/protocol.rs, +// 两边必须逐字段一致(serde round-trip 测试固化)。 + +export type CompletionExecuteTarget = + | { kind: "local"; sessionId: string } + | { kind: "ssh"; sessionId: string }; + +export interface CompletionExecuteRequest { + target: CompletionExecuteTarget; + command: string; + args: string[]; + cwd?: string | null; + /** 毫秒;completion 层 clamp 到 [200, 3000],默认 1200。 */ + timeoutMs: number; + maxOutputBytes: number; + mode: "completion-generator"; +} + +export interface CompletionExecuteResult { + exitCode: number | null; + stdout: string; + stderr: string; + truncated: boolean; + /** completion 层超时(底层进程已尝试取消回收);此时 exitCode 为 null。 */ + timedOut: boolean; +} From 5b63b0b8841b7d65dba505aef215c61abc8479d2 Mon Sep 17 00:00:00 2001 From: jinpy666 Date: Mon, 28 Sep 2026 13:43:30 +0800 Subject: [PATCH 02/22] =?UTF-8?q?docs(completion):=20FIG=20wave-1=20?= =?UTF-8?q?=E6=80=BB=E4=BD=93=E8=A7=84=E5=88=92/=E4=B8=89=20lane=20?= =?UTF-8?q?=E7=BB=86=E5=88=99/=E9=AA=8C=E8=AF=81=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E2=80=94=E2=80=94=E9=94=9A=E7=82=B9=E7=BB=8F=20fig-base=20?= =?UTF-8?q?=E5=AE=9E=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FIG_ROADMAP.zh-CN.md | 82 ++++++++++ docs/FIG_VERIFICATION.zh-CN.md | 84 ++++++++++ docs/FIG_WAVE1_LANE_A_FRONTEND_CORE.zh-CN.md | 137 ++++++++++++++++ .../FIG_WAVE1_LANE_B_COMPLETION_HOST.zh-CN.md | 154 ++++++++++++++++++ docs/FIG_WAVE1_LANE_C_FIG_SPECS.zh-CN.md | 115 +++++++++++++ 5 files changed, 572 insertions(+) create mode 100644 docs/FIG_ROADMAP.zh-CN.md create mode 100644 docs/FIG_VERIFICATION.zh-CN.md create mode 100644 docs/FIG_WAVE1_LANE_A_FRONTEND_CORE.zh-CN.md create mode 100644 docs/FIG_WAVE1_LANE_B_COMPLETION_HOST.zh-CN.md create mode 100644 docs/FIG_WAVE1_LANE_C_FIG_SPECS.zh-CN.md diff --git a/docs/FIG_ROADMAP.zh-CN.md b/docs/FIG_ROADMAP.zh-CN.md new file mode 100644 index 00000000..caf77beb --- /dev/null +++ b/docs/FIG_ROADMAP.zh-CN.md @@ -0,0 +1,82 @@ +# FIG 补全引擎总体规划(Roadmap) + +> 基线:`codex/ssh/fig-wave1-base`(main `0da8be89` + wave-1 契约 `19579413` + 本组文档)。 +> 上游方案:`docs/FIG_AUTOCOMPLETE_INTEGRATION_PLAN.zh-CN.md`(§ 编号引用均指该文档)。 +> 本文档是波次间的唯一规划入口;单波次细则见各 lane 文档。 + +## 0. 总原则(继承自方案,不重复论证) + +- Desktop OS 与 Completion Target OS 解耦:generator 只在目标机执行(§1)。 +- 不搬 Fig UI、不在 Rust 重写 parser、不把 generator 跑在桌面机(§49)。 +- 编辑操作由 parser/resolver 产生,UI 只执行(§5.2)。 +- 补全任何一层失败不得影响 PTY 输入链路(§43)。 + +## 1. 波次划分 + +### Wave 0(已完成) + +- fig-base worktree/分支建立;基线验证绿(typecheck / 128 文件 1284 用例 / build)。 +- 冻结契约类型:`frontend/src/lib/completion/core/types.ts`、`host/protocol.ts`。 +- 契约:`docs/FIG_WAVE1_CONTRACT.zh-CN.md`。 + +### Wave 1(当前波次,三 lane 并发) + +| Lane | 分支 | 交付 | 细则 | +|---|---|---|---| +| A 前端核心 | `codex/ssh/fig-wave1-frontend-core` | CompletionItem/Edit 内核、键盘所有权纯函数、CompletionController、legacy adapter 零回归接线、feature flag + 设置项(七语) | `FIG_WAVE1_LANE_A_FRONTEND_CORE.zh-CN.md` | +| B Rust Host | `codex/ssh/fig-wave1-completion-host` | `completion/execute` RPC(local + ssh target)、安全策略、PROTOCOL 文档、smoke 脚本 | `FIG_WAVE1_LANE_B_COMPLETION_HOST.zh-CN.md` | +| C Fig 管线 | `codex/ssh/fig-wave1-fig-specs` | fig 运行时类型、静态 adapter、sync/verify 脚本、11 命令 spec snapshot、体积实测报告 | `FIG_WAVE1_LANE_C_FIG_SPECS.zh-CN.md` | + +三 lane 文件归属互斥(契约 §6),合并顺序任意;集成阶段见 `FIG_VERIFICATION.zh-CN.md`。 + +**Wave 1 出口判定**:三 lane 全绿合入集成分支 + 全仓门禁通过 + 验收清单(验证文档 §4)勾完。 + +### Wave 2(依赖 wave 1 全部合入) + +1. **fig provider 接线**:C 的 adapter 包装成 `FigCompletionProvider` 进 controller + provider 链(fig > legacy,§35);feature flag `ssh-completion-engine` 默认值 + 评估切 `fig-safe`。 +2. **声明式 generator 端到端**:`git checkout ` / `kubectl get pods` 经 + B 的 `completion/execute` 打到目标机;stale-guard(revision)+ TTL 缓存 + (§29)+ 取消(§30)+ scheduler 两段渲染(§31:静态先行、动态合并)。 +3. **`completion/listDirectory` RPC** + 文件 provider(local fs / SFTP readdir, + §17)。 +4. **Worker spike(决策门)**:DBX WebView(WKWebView/WebView2)里 + `new Worker(blob/data-url)` 可用性验证;可用则把 engine 迁 Worker + (接口已留 requestId/revision),不可用则永久主线程并记录决策。 +5. **spec 集合扩张**:按 C 的体积报告决定默认集是否扩到全量。 + +### Wave 3 + +- WSL executor(`wsl.exe -d `,路径映射,§14)。 +- custom JS generator compatibility shim(§15 Level 3,host facade)。 +- 全量 spec corpus + legacy specs 退役(§35/§52 Step 13-14)。 +- 任意光标位置补全(§23,行尾→行内)。 +- `completion/environment` RPC(target os/shell/cwd 元信息)。 + +## 2. 关键决策记录(已定,不再重议) + +| # | 决策 | 依据 | +|---|---|---| +| D1 | wave 1 引擎跑主线程,不做 Worker 化 | `frontend/build.mjs` 单文件内联 + 断言唯一 script 标签;parser 为纯同步计算,当前量级无性能需求 | +| D2 | spec 全量静态进 bundle,不做按命令懒加载 chunk | 同上构建约束;体积取舍由 Lane C 实测报告在 wave 2 裁决 | +| D3 | completion 超时(默认 1.2s)在 completion 层竞速实现,不改 `SshRuntime::exec` 的 `clamp(5,300)` 下限;超时走既有 `cancel_exec` 回收 | 避免动共享 exec 语义;契约 §5 | +| D4 | read-only 连接对 `completion/execute` 一律拒绝(含 local 语义对齐:SSH target 才有 read-only 概念) | generator=命令执行,不能绕过只读承诺 | +| D5 | 联网只发生在显式 `pnpm fig:sync`;测试一律离线 fixture | SKILL 红线 + CI 可重复 | +| D6 | wave 1 flag 默认 `legacy`(行为与 HEAD 完全一致);`fig-safe` 值可设但等 wave 2 接线后才生效 | 零回归承诺 | + +## 3. 风险登记 + +| 风险 | 缓解 | +|---|---| +| bundle 体积超预期(kubectl/aws 巨型 spec) | C 产出实测报告 + verify 脚本设预算阈值;wave 2 可缩 allowlist | +| DBX WebView 不支持 Worker | wave 2 spike 前不依赖;主线程架构已是兜底形态 | +| completion/execute 被视为命令执行面变更 | agent-flow 规定 human review;B 的 PR 必须人工审后才能合(agent 不自合) | +| 远端 1.2s 超时在高 RTT 链路误杀 | 超时可配(clamp 上限 3s);wave 2 generator 结果有 TTL 缓存,重复触发少 | +| App.vue 接线回归(13789 行单文件) | A 的 golden parity 测试固化 HEAD 行为;键盘语义表驱动单测;UI mock 走查沿用 | + +## 4. 治理 + +- agent 不 push / 不 merge / 不安装;集成与发版归 integrator/用户。 +- B 属命令执行面变更,PR 需人工 review(`.github/agent-flow.yml` security 段)。 +- 冻结类型变更只能由协调者统一裁决后同步全 lane。 diff --git a/docs/FIG_VERIFICATION.zh-CN.md b/docs/FIG_VERIFICATION.zh-CN.md new file mode 100644 index 00000000..c4a48c57 --- /dev/null +++ b/docs/FIG_VERIFICATION.zh-CN.md @@ -0,0 +1,84 @@ +# FIG Wave 1 验证计划 + +> 环境统一:`export PATH="$HOME/.nvm/versions/node/v22.21.0/bin:$HOME/Library/pnpm:$HOME/.cargo/bin:$PATH"` + +## 1. 各 lane 交付门禁(agent 完成前自查,全绿才算完成) + +### Lane A / C(前端) + +```bash +pnpm --dir /frontend install --prefer-offline +pnpm --dir /frontend typecheck +pnpm --dir /frontend test # A:既有 128 文件零失败 + 新增 spec 全过;C:同 + fig 用例 +pnpm --dir /frontend build +``` + +C 追加:`pnpm --dir /frontend fig:verify`;sync 幂等性自查一次。 + +### Lane B(后端) + +```bash +cargo fmt --manifest-path /backend/Cargo.toml --check +cargo clippy --locked --manifest-path /backend/Cargo.toml --all-targets -- -D warnings +cargo test --locked --manifest-path /backend/Cargo.toml +# docker 容器可用时(见 SKILL 测试容器章节): +python3 /scripts/smoke_completion.py +``` + +`git -C diff --stat codex/ssh/fig-wave1-base` 必须只落在契约 §6 +归属文件内(B 的 ssh.rs 最小只读查询例外需在报告中列明)。 + +## 2. 集成阶段(三 lane 合入后,integrator/协调者执行) + +```bash +# 集成分支:从 fig-wave1-base 起,依序 merge 三条 lane 分支(文件互斥,顺序任意) +git checkout -b codex/ssh/fig-wave1-integration codex/ssh/fig-wave1-base +git merge codex/ssh/fig-wave1-frontend-core +git merge codex/ssh/fig-wave1-completion-host +git merge codex/ssh/fig-wave1-fig-specs + +# 全仓门禁(对齐 agent-flow validation.local) +python3 scripts/validate_repo.py +python3 scripts/check_vendor_lockstep.py +python3 scripts/verify_rdp_vendor_integrity.py +node scripts/connection-forms/verify.mjs +pnpm --dir frontend typecheck && pnpm --dir frontend test && pnpm --dir frontend build +node scripts/smoke_ui_mock.mjs +node scripts/smoke_ui_settings.mjs +pnpm --dir frontend fig:verify # C 产物在集成态复验 +cargo fmt --manifest-path backend/Cargo.toml --check +cargo clippy --locked --manifest-path backend/Cargo.toml --all-targets -- -D warnings +cargo test --locked --manifest-path backend/Cargo.toml +python3 scripts/smoke_completion.py # 容器可用时 +``` + +## 3. 行为回归红线(任一破坏即回退整改) + +1. **PTY 输入链路零影响**:A 的 golden parity 测试 + 键盘表驱动单测全绿; + completion 任何异常(engine 抛错/存储读失败)不冒泡到 onData 路径。 +2. **默认行为 = HEAD**:无 `ssh-completion-engine` 存储时,浮层/键盘/接受 + 行为与基线逐项一致(手动清单:git ch、git checkout - 进值层、 + hint 行 Tab 透传、Enter 恒执行、Esc 关闭、总开关关→零浮层)。 +3. **既有 ssh/exec 语义零改动**:B 分支 `git diff codex/ssh/fig-wave1-base -- backend/src/ssh.rs backend/src/exec.rs` + 除预批的最小只读查询 fn 外为空。 +4. **零新增运行时依赖**:`frontend/package.json` dependencies 与 + `backend/Cargo.toml` [dependencies] 无新增项(scripts/devDeps 变更需报告获批)。 + +## 4. Wave 1 验收清单(对齐方案 §51 的 wave-1 子集) + +- [ ] CompletionItem/CompletionEdit/EditBuffer 落地且 legacy 零回归(A) +- [ ] 键盘所有权规则表驱动固化,Enter/hint-Tab 透传不可回归(A) +- [ ] feature flag `ssh-completion-engine` + 设置项七语(A) +- [ ] `completion/execute` local/SSH 双 target、超时/上限/取消/只读拒绝(B) +- [ ] PROTOCOL 文档 + smoke 用例(SKIP 语义正确)(B) +- [ ] 11 spec snapshot + manifest + verify 脚本(C) +- [ ] 体积实测报告与 wave 2 默认集建议(C) +- [ ] 三 lane 全绿 + 集成全仓门禁全绿 +- [ ] lane 报告齐备(契约 §8 格式:base SHA/commits/files/验证/偏差/风险/follow-up) + +## 5. 回滚策略 + +- 每 lane 独立分支,任一 lane 失败可单独弃置,不影响其余两条。 +- B/C 合入后默认不改变任何用户可见行为(flag 默认 legacy;completion/execute + 无调用方),可安全随版本携带;A 是唯一行为敏感面,靠 parity 测试兜底。 +- 集成分支问题 → 弃集成分支重做,不动 fig-wave1-base 与各 lane 分支。 diff --git a/docs/FIG_WAVE1_LANE_A_FRONTEND_CORE.zh-CN.md b/docs/FIG_WAVE1_LANE_A_FRONTEND_CORE.zh-CN.md new file mode 100644 index 00000000..6cc3b706 --- /dev/null +++ b/docs/FIG_WAVE1_LANE_A_FRONTEND_CORE.zh-CN.md @@ -0,0 +1,137 @@ +# Lane A 细则:前端补全核心(frontend-core) + +> 分支 `codex/ssh/fig-wave1-frontend-core`,基线 `codex/ssh/fig-wave1-base`。 +> 先读:`FIG_WAVE1_CONTRACT.zh-CN.md`、`FIG_ROADMAP.zh-CN.md`、`FIG_VERIFICATION.zh-CN.md`。 +> 冻结类型 `frontend/src/lib/completion/core/types.ts` 只 import 不改。 + +## 1. 目标 / 非目标 + +目标:把补全的「解析→候选→键盘→接受」从 App.vue 收进可测试的模块层, +行为与 HEAD **零回归**;为 wave 2 的 fig provider / generator 接线留好插槽。 + +非目标:Worker 化、fig spec 接线、动态 provider 行为变更、overlay/定位改动、 +`lib/completions/spec.ts`(legacy parser)语义改动、build.mjs。 + +## 2. 新增文件与签名 + +### `frontend/src/lib/completion/core/edit.ts` + +```ts +export interface AppliedEdit { text: string; cursor: number } +/** 把 CompletionEdit 应用到行文本;cursorOffset 缺省 = edit.text.length。 */ +export function applyEditToText(text: string, edit: CompletionEdit): AppliedEdit +/** 行尾 token 替换的 edit 构造(legacy adapter 用;addSpace 时 text 尾补空格)。 */ +export function trailingTokenEdit(text: string, token: string, addSpace: boolean): CompletionEdit +``` + +### `frontend/src/lib/completion/core/ranking.ts` + +```ts +export const MAX_COMPLETION_ITEMS = 20; +/** score 降序、同分 label 字典序、截断;纯函数,引擎唯一排序出口。 */ +export function rankItems(items: CompletionItem[]): CompletionItem[] +``` + +### `frontend/src/lib/completion/keyboard.ts`(契约 §4.2 的固化) + +```ts +export interface CompletionKeyboardState { + menuOpen: boolean; + hasItems: boolean; + /** 高亮项 kind:null=无高亮;"hint"=动态占位行(Tab 透传)。 */ + activeItemKind: CompletionItemKind | null; + loading: boolean; +} +export type CompletionKeyAction = "accept" | "passthrough" | "next" | "prev" | "close" | "none"; +export function resolveCompletionKey(state: CompletionKeyboardState, key: string): CompletionKeyAction +``` + +规则(必须表驱动单测全覆盖,缺一不可): + +| menuOpen | activeItemKind | Enter | Tab | ArrowUp/Down | Escape | +|---|---|---|---|---|---| +| true | subcommand/option/argument/… | passthrough | accept | next/prev | close | +| true | hint(或 hasItems=false / loading) | passthrough | passthrough | next/prev(仅 hasItems) | close | +| false | — | passthrough | passthrough | passthrough | none | + +### `frontend/src/lib/completion/legacy/legacySpecAdapter.ts` + +把 `matchSpecLine(line, COMPLETION_SPECS)` 包装成引擎 resolver: + +```ts +export interface LegacyResolveInput { line: string; requestId: number; revision: number; sessionId: string } +export function legacyResolve(input: LegacyResolveInput): CompletionResponse +``` + +映射规则(**逐字段保真,零回归的根**): + +- `SpecMatch.rows[].kind`:`sub→subcommand`、`flag→option`、`value→argument`、`hint→hint`。 +- `edit` = `{ text: row.token + (row.space ? " " : ""), replaceStart: match.replaceStart, replaceEnd: match.replaceEnd }`(fig-base 的 `SpecMatch` 已带精确边界,见 `lib/completions/spec.ts` `SpecMatch` 定义)。 +- `label/description/score` 原样;`source: "legacy-spec"`;`id` 用 `legacy:{commandPath}:{label}:{i}` 稳定串。 +- `context`:`command = commandPath[0] ?? null`,`tokenStart=match.replaceStart`,`tokenEnd=match.replaceEnd`。 +- `matchSpecLine` 返回 null → `state: "pass-through"`、`items: []`(回落历史建议浮层,由 App.vue 现有逻辑处理)。 +- rows 空(spec 命中无候选)同样 `pass-through`。 + +### `frontend/src/lib/completion/CompletionController.ts` + +```ts +export interface CompletionControllerOptions { + sessionId: () => string; + readLine: () => string; // 返回 pendingTerminalInput 当前值 + enabled: () => boolean; // 总开关 + 引擎开关合成后的判定 + debounceMs?: number; // 默认 90 + onResponse: (response: CompletionResponse) => void; + onAcceptEdit: (edit: CompletionEdit) => void; // App.vue 执行终端写入 +} +export class CompletionController { + /** App.vue 在行缓冲每个变更点调用:revision++ 并调度 request("typing")。 */ + lineChanged(): void; + request(trigger: CompletionTrigger): void; + accept(item: CompletionItem): void; + dismiss(): void; + /** 会话切换:重置 revision/requestId,丢弃在途结果(sessionId guard)。 */ + resetSession(): void; +} +``` + +纪律(单测必须覆盖): + +1. 响应回来时 `requestId`、`revision`、`sessionId` 三者任一不匹配当前态 → 静默丢弃。 +2. `resolve` 全程 try/catch;任何异常 → `state:"pass-through"` 空响应,绝不抛到调用方(PTY 红线)。 +3. debounce 期间的多次 `lineChanged` 只发一次请求。 +4. `enabled()===false` → 直接 pass-through,不调度。 + +wave 1 的 resolver 就是 `legacyResolve`;provider 链(fig)留 wave 2,不在本 lane 实现。 + +## 3. App.vue 接线(锚点为 fig-base 行号,允许 ±小漂移,以函数名为准) + +| 位置 | 改造 | +|---|---| +| `COMPLETION_SPEC_ENABLED_KEY` ≈L913 / `completionSpecEnabled()` ≈L925 | 保留总开关;新增 `COMPLETION_ENGINE_KEY = "ssh-completion-engine"`,读值 `legacy`(默认)/`fig-safe`,wave 1 两种值都走 legacy resolver | +| `openCompletionMenu(match)` ≈L940 | 改为消费 `CompletionResponse`:items 映射进现有 `completionRows/Level/CommandPath/ActiveIndex/Anchor` refs(Level 由 context+activeKind 推导,保持现有三层展示语义) | +| `handleCompletionKey(event)` ≈L995 | 改为:构造 `CompletionKeyboardState` → `resolveCompletionKey` → 按 action 执行(accept 走 `controller.accept`;passthrough 返回 false;close `closeCompletionMenu`)。**Enter 恒放行、hint 行 Tab 放行的现语义必须保持**(由键盘单测背书) | +| `acceptCompletionRow(row)` ≈L1030 | 改为 `controller.accept(item)` → `onAcceptEdit(edit)` → `applyEditToText(pendingTerminalInput, edit)` → 沿用 `replaceTerminalLineWith(nextLine, false)`(整行擦重打的现机制不动)→ `controller.lineChanged()` 刷新 | +| `refreshCompletionMenu()` ≈L1050 / `refreshSuggestionsAfterInput()` ≈L3069 | 内层的 `matchSpecLine` 直调替换为 `controller.request("manual"/"typing")`;历史建议/ghost 分支**一行不动** | +| `trackPendingInput` ≈L3020 / `replaceTerminalLineWith` ≈L3230 / Enter/Ctrl+C 清行点 / ghost 接受点 | 每处行缓冲变更后补 `controller.lineChanged()`(一行调用,不改既有逻辑) | +| 会话切换/关闭 | `controller.resetSession()` | + +## 4. 设置项与 i18n + +- `frontend/src/lib/pluginStore.ts`:`PLUGIN_STORE_KEYS` 追加 `"ssh-completion-engine"`(本 lane 唯一允许改此文件的一行;B/C 不碰它)。 +- `SettingsDialog.vue`:在现有 `ssh-completion-spec` 开关(≈L270)旁加引擎 Select(reka-ui wrapper,参照同文件既有 Select 用法):`legacy` / `fig-safe`;`fig-safe` 项描述注明「wave 2 生效」。 +- `i18n.ts`:新增 key(如 `settings.completion.engine`、`.engineLegacy`、`.engineFigSafe`、`.engineHint`)七语全补(zh-CN/zh-TW/en/es/it/ja/pt)。 + +## 5. 测试清单(`*.spec.ts` 同目录) + +- `edit.spec.ts`:trailingTokenEdit 边界(尾空格/空行=纯插入点、引号 token、`--flag=val`);applyEditToText cursorOffset。 +- `ranking.spec.ts`:排序确定性、截断 20。 +- `keyboard.spec.ts`:§2 表全组合(≥10 用例)。 +- `legacySpecAdapter.spec.ts`:**golden parity**——对现有 `spec.spec.ts` 语料 + specs/index 全量 spec,断言 controller 输出与 `matchSpecLine` 直查在 label/kind/顺序/描述上逐一相等。 +- `CompletionController.spec.ts`:三重 guard(revision/requestId/sessionId)、debounce 合并、异常降级 pass-through、accept→onAcceptEdit 的 edit 正确、enabled=false。 +- 既有 `spec.spec.ts` / `CompletionMenu.spec.ts` 必须零修改通过。 + +## 6. 验收 + +1. `pnpm --dir frontend typecheck && pnpm --dir frontend test && pnpm --dir frontend build` 全绿。 +2. 手动清单(UI mock 或 dev):`git ch` 填充、`git checkout -` 进值层、hint 行 Tab 透传、Enter 恒执行、Esc 关闭、总开关关闭后零浮层——与 HEAD 行为一致。 +3. 默认路径(无 `ssh-completion-engine` 存储)行为与 HEAD 完全一致(parity 测试背书)。 diff --git a/docs/FIG_WAVE1_LANE_B_COMPLETION_HOST.zh-CN.md b/docs/FIG_WAVE1_LANE_B_COMPLETION_HOST.zh-CN.md new file mode 100644 index 00000000..92c96df6 --- /dev/null +++ b/docs/FIG_WAVE1_LANE_B_COMPLETION_HOST.zh-CN.md @@ -0,0 +1,154 @@ +# Lane B 细则:Rust CompletionHost(completion-host) + +> 分支 `codex/ssh/fig-wave1-completion-host`,基线 `codex/ssh/fig-wave1-base`。 +> 先读:`FIG_WAVE1_CONTRACT.zh-CN.md`(§5 RPC 契约)、`FIG_ROADMAP.zh-CN.md`、`FIG_VERIFICATION.zh-CN.md`。 +> 前端线协议 `frontend/src/lib/completion/host/protocol.ts` 是冻结镜像,两边字段必须逐字一致。 + +## 1. 目标 / 非目标 + +目标:新增 sidecar 方法 `completion/execute`,把 generator 命令执行到正确的 +target(local 短进程 / SSH 复用既有 exec),带超时、输出上限、安全校验。 + +非目标:`completion/listDirectory`、`completion/environment`、WSL、修改 +`SshRuntime::exec` / `exec.rs` 的既有语义、前端任何文件。 + +## 2. 既有锚点(fig-base 已核实) + +- 路由分发:`backend/src/main.rs` `handle_request` 的 `match path`,`"ssh/exec"` 臂 ≈L420(`required_string` 取参 + `self.runtime.block_on(self.ssh.exec(...))`),`"ssh/exec/cancel"` 臂 ≈L440(`self.ssh.cancel_exec(exec_id)`,同步)。 +- `SshRuntime::exec(session_id, exec_id: Option<&str>, command, sudo, timeout_secs)`:内部 `clamp(5,300)`;`sudo && read_only` 拒绝。 +- shell 转义:仓库硬性约定走既有 `exec::shell_quote`(见 `docs/PROTOCOL.zh-CN.md` WT-4 节描述;实现于 `backend/src/exec.rs`,使用前先 grep 确认确切路径与签名)。 +- `Cargo.toml`:`tokio = { features = ["full"] }`、`uuid = { features=["v4"] }` 已就位——**不新增任何依赖,Cargo.lock 不动**。 + +## 3. 新增文件 + +### `backend/src/completion/mod.rs` + +模块声明与 re-export(`protocol`、`security`、`executor`、`local`、`ssh`)。 + +### `backend/src/completion/protocol.rs` + +与 `host/protocol.ts` 逐字段对应的 DTO(camelCase): + +```rust +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum CompletionTarget { Local { session_id: String }, Ssh { session_id: String } } + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompletionExecuteRequest { + pub target: CompletionTarget, + pub command: String, + pub args: Vec, + pub cwd: Option, + pub timeout_ms: u64, + pub max_output_bytes: usize, + pub mode: String, +} + +#[derive(Debug, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CompletionExecuteResult { + pub exit_code: Option, + pub stdout: String, + pub stderr: String, + pub truncated: bool, + pub timed_out: bool, +} +``` + +round-trip 测试:对本地/SSH target、缺省字段的 JSON 反序列化快照。 + +### `backend/src/completion/security.rs` + +```rust +pub const MIN_TIMEOUT_MS: u64 = 200; +pub const MAX_TIMEOUT_MS: u64 = 3000; +pub const DEFAULT_TIMEOUT_MS: u64 = 1200; +pub const MAX_OUTPUT_BYTES: usize = 256 * 1024; +pub const MAX_ARGS: usize = 32; + +/// 校验+收紧:mode 必须是 "completion-generator";command 非空且不含 NUL; +/// args 数 ≤32 且不含 NUL;timeout_ms 缺省/越界 → clamp;max_output_bytes 越界 → clamp。 +pub fn validate_and_clamp(req: &mut CompletionExecuteRequest) -> Result<(), String> +/// 远端命令行拼装:command + 空格 + args 逐个 shell_quote(拒绝注入面)。 +pub fn build_remote_command_line(command: &str, args: &[String]) -> String +``` + +错误串统一 `completion:` 前缀(如 `completion: mode not allowed`)。 + +### `backend/src/completion/local.rs` + +短生命周期子进程执行(**绝不碰** `local_terminal.rs` 的交互 PTY): + +- `tokio::process::Command::new(&command).args(&args)`,stdout/stderr piped,`cwd` 可选,`kill_on_drop(true)`。 +- 输出读取带 `max_output_bytes` 上限:超限即 `truncated=true` 并停止读取、杀进程。 +- `tokio::time::timeout(clamped)` 竞速;超时 kill + `timed_out=true`、`exit_code=None`。 +- 平台注意:argv 直 exec 不经 shell,Windows 无需引号处理;大输出/超时用例 `#[cfg(unix)]` 用 `yes`/`sleep`,Windows 跳过并在测试注释说明。 + +### `backend/src/completion/ssh.rs` + +- **read-only 门(决策 D4)**:SSH target 在只读连接上一律拒绝。实现优先复用 + `SshRuntime` 已有的公开会话信息读取(grep `read_only` 的现有用法找最小入口); + 若确无可复用的公开入口,允许在 `backend/src/ssh.rs` **追加一个最小只读查询 + fn**(如 `pub async fn completion_session_read_only(&self, session_id) -> Result`), + 仅此一处、不改任何既有函数——这是对本 lane 文件归属的唯一预批例外,必须写进报告。 +- `exec_id = Some(concat!("completion-", Uuid::new_v4()))`。 +- 命令行:`build_remote_command_line`(逐参数 shell_quote)。 +- 竞速超时(决策 D3):`timeout(clamped)` 包住 `self.ssh.exec(session_id, exec_id, &line, false, None)`; + 超时后调 `self.ssh.cancel_exec(exec_id)` 回收,返回 `timed_out=true` 空输出。 + **不修改 exec 的内部 clamp**。 +- `exec` 返回 `Value`:按 `ssh/exec` 现有返回字段(stdout/stderr/exitCode,以 + main.rs/ssh.rs 实际为准)映射到 `CompletionExecuteResult`;字段名不一致时做 + 显式映射并注释。 + +### `backend/src/completion/executor.rs` + +按 `target.kind` 分派到 local/ssh 的统一入口(供 main.rs 调用),签名自定, +错误统一 `Result`(sidecar 字符串 Err 惯例)。 + +### `backend/src/main.rs`(仅此一处改动) + +- `mod completion;` +- 新增 `"completion/execute" =>` 臂:`serde_json::from_value` 反序列化 → `security::validate_and_clamp` → `completion::executor::dispatch`(SSH 分支需要 `&self.ssh`)→ 序列化返回。照抄 `ssh/exec` 臂的取参/block_on 风格;该臂无审计调用则不加,有则同款。 + +## 4. 协议文档 + +`docs/PROTOCOL.zh-CN.md` 追加 `## completion/execute(补全 generator 执行)` 小节, +文体对齐 WT-4 节( prose + 加粗要点):参数表(camelCase)、返回字段、语义 +(target-side 执行、sudo 恒 false、read-only 拒绝、超时竞速+取消、输出上限)、 +错误前缀 `completion:`。 + +## 5. smoke 脚本 `scripts/smoke_completion.py` + +对齐 `smoke_fs_test.py` 约定(`Method not found` → SKIP;CaseResult 记账; +前置用例依赖)。用例: + +1. local echo:`command="printf", args=["hello"]` → stdout `hello`。 +2. local 超时:`sleep 5` + `timeoutMs=400` → `timedOut=true`(unix;win SKIP)。 +3. local 截断:`yes x` + `maxOutputBytes=1024` → `truncated=true`(unix)。 +4. 安全拒绝:`mode="evil"` → 报错;`command=""` → 报错。 +5. ssh 基础:容器会话 `printf hi` → stdout `hi`(复用 smoke_test 的容器启动方式)。 +6. ssh quote:args 带空格/单引号(`["a b'c"]`)→ stdout 原样回显。 +7. ssh 未知 session → 报错。 +8. ssh 超时:远端 `sleep 5` + 400ms → `timedOut=true`(若容器无 sleep 则 SKIP)。 +9. read-only 拒绝:若 smoke 现有框架能建只读连接则验,否则记 SKIP+TODO。 + +## 6. Cargo 测试 + +protocol round-trip(含 target tag 两种);security 全规则;`build_remote_command_line` +(空格/单引号/unicode/空 args);local 成功/超时/截断(平台守卫);ssh 层仅测 +纯函数(拼装+门控逻辑),真链路由 smoke 覆盖。 + +## 7. 验收 + +```bash +cargo fmt --manifest-path backend/Cargo.toml --check +cargo clippy --locked --manifest-path backend/Cargo.toml --all-targets -- -D warnings +cargo test --locked --manifest-path backend/Cargo.toml +# docker 容器可用时: +python3 scripts/smoke_completion.py +``` + +零新依赖;`Cargo.lock`、`frontend/`、既有 ssh/exec 行为零改动;PR 需人工 review +(命令执行面变更)后由 integrator 合入。 diff --git a/docs/FIG_WAVE1_LANE_C_FIG_SPECS.zh-CN.md b/docs/FIG_WAVE1_LANE_C_FIG_SPECS.zh-CN.md new file mode 100644 index 00000000..a87eef3e --- /dev/null +++ b/docs/FIG_WAVE1_LANE_C_FIG_SPECS.zh-CN.md @@ -0,0 +1,115 @@ +# Lane C 细则:Fig Spec 管线(fig-specs) + +> 分支 `codex/ssh/fig-wave1-fig-specs`,基线 `codex/ssh/fig-wave1-base`。 +> 先读:`FIG_WAVE1_CONTRACT.zh-CN.md`、`FIG_ROADMAP.zh-CN.md`(决策 D2/D5)、`FIG_VERIFICATION.zh-CN.md`。 +> 冻结类型 `core/types.ts` 只 import 不改;**不碰 App.vue**(接线是 wave 2)。 + +## 1. 目标 / 非目标 + +目标:建立「上游 spec → 归一化 snapshot → 静态 adapter」的 build-time 管线, +产出 11 个命令的 spec bundle 与体积实测报告,为 wave 2 接线备料。 + +非目标:把 fig provider 接进 controller/设置、generator 执行(B lane + wave 2)、 +custom JS generator、全量 corpus、修改 `lib/completions/` 下任何既有文件 +(`figImport.ts` 保留原样,新代码放 `lib/completion/fig/`)。 + +## 2. 目录 + +```text +frontend/src/lib/completion/fig/ +├── types.ts # 归一化后的 fig 运行时类型(见 §3) +├── normalize.ts # 上游原始 spec 对象 → 归一化(纯函数,sync 脚本与测试共用) +├── adapter.ts # resolveFigLine:归一化 spec → CompletionItem[](纯函数) +└── fixtures/git.fig.json # 手工裁剪的稳定 git spec 快照(测试离线用) +frontend/vendor/fig-specs/ +├── LICENSE / NOTICE.fig.txt +├── snapshot.json # {source, commit, generatedAt, formatVersion:1, sizes} +├── build/.ts # 归一化 spec 数据模块(纯数据,import type 引类型) +└── spec-manifest.generated.ts # 静态 import map(不做懒加载,决策 D2) +scripts/sync_fig_specs.mjs # 唯一联网点(决策 D5) +scripts/verify_fig_specs.mjs # 离线校验 +``` + +已核实:`frontend/vendor/` 不受 `check_vendor_lockstep.py`(只管 backend/vendor +RDP 链)与 `validate_repo.py`(固定 standalone 路径清单)约束。 + +## 3. `types.ts`(归一化形态,非上游原始形态) + +```ts +export interface FigGeneratorDecl { kind: "script"; script: string[]; splitOn?: string } // wave1 只存声明不执行 +export interface FigArg { name?: string; description?: string; isVariadic?: boolean; isOptional?: boolean; suggestions?: string[]; generators?: FigGeneratorDecl[] } +export interface FigOption { names: string[]; description?: string; args?: FigArg | null; isRepeatable?: boolean; isPersistent?: boolean; isRequired?: boolean } +export interface FigSubcommand { name: string; aliases?: string[]; description?: string; subcommands?: FigSubcommand[]; options?: FigOption[]; args?: FigArg[] } +export interface FigSpecRoot { name: string; aliases?: string[]; description?: string; subcommands?: FigSubcommand[]; options?: FigOption[]; args?: FigArg[] } +``` + +设计要点:`Option.name: string | string[]` 归一为 `names: string[]`(含长/短 +名原样,`--` 前缀保留);`isPersistent` 保留并在 adapter 里沿子命令树下传; +函数型 generator 只留 `{kind:"script", script}` 声明,`postProcess` 等 JS 函数 +**丢弃**(wave 2 用 B 的 RPC + 前端 postProcess 兜底,snapshot 不存代码)。 + +## 4. `normalize.ts` + +输入:node 直接 import 上游 `src/.ts` 得到的默认导出(多数是纯对象; +个别含函数/模板——函数字段按 §3 规则丢弃或降级)。输出:`FigSpecRoot`。 +规则:别名数组保留;`args` 取首个 required 之外的可选链(`isOptional` 标记); +子命令树不截深度(legacy 的两层限制不适用于 fig 路线);`loadSpec`/ +`generateSpec` 指令 → 记录为该子命令 `generators: []` + 保留原节点(wave 3 处理)。 + +## 5. `adapter.ts` + +```ts +export interface FigResolveResult { items: CompletionItem[]; context: CompletionContext } +export function resolveFigLine(line: string, specs: readonly FigSpecRoot[]): FigResolveResult | null +``` + +- 复用 `lib/completions/spec.ts` 的 `splitCommandLine`(只读 import)。 +- 能力必须超出 legacy:别名命中(`git co` → checkout)、persistent options 沿树下传、 + variadic args(多个位置 token 持续补)、repeatable option 不因已出现而消失、 + 子命令树无深度限制、`--` 终结、`--flag=value` 内联值层。 +- `edit` 用与 legacy adapter 相同的行尾 token 边界语义(replaceStart/replaceEnd); + `source: "fig-spec"`;无命中 → null(调用方回落 legacy → 历史)。 +- 测试全走 `fixtures/git.fig.json`(离线):alias、persistent、variadic、`--`、 + inline `=`、深度子命令 ≥8 个用例;另加一个「fig 与 legacy 对 git 同行输入 + 候选对比」的信息性用例(允许 fig 更丰富,断言 fig ⊇ legacy 的静态部分)。 + +## 6. `scripts/sync_fig_specs.mjs`(唯一联网点) + +1. `FIG_AUTOCOMPLETE_REF`(缺省用 snapshot.json 已记录 pin;首次为当前默认 pin, + 落盘新 pin)clone/fetch `withfig/autocomplete` 到 `.tmp/`(脚本自清)。 +2. allowlist(wave 1,方案 §36 M3):git, docker, kubectl, helm, npm, pnpm, + yarn, ssh, aws, cargo, systemctl。 +3. node ≥22.18 原生 type-stripping `import()` 每个上游 `src/.ts` + (版本不满足直接报错,不静默降级);`normalize` 后 emit + `frontend/vendor/fig-specs/build/.ts`(`import type { FigSpecRoot } from "../../../src/lib/completion/fig/types"` + `export const spec: FigSpecRoot = {...}`)。 +4. 生成 `spec-manifest.generated.ts`(静态 `import` + `Record`) + 与 `snapshot.json`;拷贝上游 LICENSE → `vendor/fig-specs/LICENSE`,写 NOTICE + (含方案 §48 的 attribution 文案)。 +5. 打印每 spec 归一化后 KB 与总量;对上游 spec 若 import 失败(非纯对象), + 记入 snapshot.json 的 `skipped[]` 并警告,不中断其余。 +6. **emit 的模块必须纯数据**:写入前断言序列化结果不含 `"function"`。 + +`scripts/verify_fig_specs.mjs`(离线,CI 可用):manifest↔文件一致、snapshot +pin 存在、LICENSE/NOTICE 存在、纯数据断言、体积预算(单 spec >150KB 或总量 +>600KB → 非零退出;首测后可调阈值,调整写进报告)。 + +`frontend/package.json` scripts 增加:`fig:sync` / `fig:verify` / `fig:test` +(= `vitest run src/lib/completion`)。**不新增运行时依赖**;build-time 依赖 +同样目标为零(type-stripping 足够);若确需 devDependency,报告中论证并给出 +替代方案,未获批前不写入。 + +## 7. 体积实测报告 `docs/fig-specs-size-report.md` + +- 表:每 spec(raw KB / 归一化 KB);总量。 +- bundle 影响:`pnpm build` 前后 `ui/index.html` 字节数(manifest 引入 vs + 临时注释掉 manifest 导出做对照)。 +- 结论建议:wave 2 默认集(若超预算给出裁剪序:aws → kubectl → helm …)。 + +## 8. 验收 + +1. `pnpm fig:sync` 幂等可重跑(同 pin 二次运行 diff 为空)。 +2. `pnpm fig:verify` 通过。 +3. `pnpm --dir frontend typecheck && test && build` 全绿(vendor 数据模块参与 + typecheck/build 不报错)。 +4. 体积报告成文,含明确「wave 2 默认集」建议。 +5. 全程除 `pnpm fig:sync` 外无网络行为;测试零联网。 From 448e92d84186a00cd08f5ba4d860ff87c95251eb Mon Sep 17 00:00:00 2001 From: jinpy666 Date: Mon, 28 Sep 2026 14:05:19 +0800 Subject: [PATCH 03/22] =?UTF-8?q?docs(completion):=20=E6=9C=80=E7=BB=88?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E5=AF=B9=E9=BD=90=E2=80=94=E2=80=94vendor=20?= =?UTF-8?q?amazon-q=20parser+=E5=85=A8=E9=87=8F=E8=AF=AD=E6=96=99=E3=80=81?= =?UTF-8?q?legacy=20=E9=80=80=E5=BD=B9=EF=BC=9B=E5=86=BB=E7=BB=93=20tokeni?= =?UTF-8?q?ze/source=20=E6=8E=A5=E7=BC=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/FIG_ROADMAP.zh-CN.md | 119 +++++------- docs/FIG_VERIFICATION.zh-CN.md | 100 +++++----- docs/FIG_WAVE1_CONTRACT.zh-CN.md | 121 ++++-------- docs/FIG_WAVE1_LANE_A_FRONTEND_CORE.zh-CN.md | 183 +++++------------- .../FIG_WAVE1_LANE_B_COMPLETION_HOST.zh-CN.md | 4 + docs/FIG_WAVE1_LANE_C_FIG_SPECS.zh-CN.md | 175 ++++++----------- .../src/lib/completion/core/tokenize.spec.ts | 52 +++++ frontend/src/lib/completion/core/tokenize.ts | 114 +++++++++++ frontend/src/lib/completion/fig/source.ts | 30 +++ 9 files changed, 458 insertions(+), 440 deletions(-) create mode 100644 frontend/src/lib/completion/core/tokenize.spec.ts create mode 100644 frontend/src/lib/completion/core/tokenize.ts create mode 100644 frontend/src/lib/completion/fig/source.ts diff --git a/docs/FIG_ROADMAP.zh-CN.md b/docs/FIG_ROADMAP.zh-CN.md index caf77beb..62f622b6 100644 --- a/docs/FIG_ROADMAP.zh-CN.md +++ b/docs/FIG_ROADMAP.zh-CN.md @@ -1,82 +1,67 @@ -# FIG 补全引擎总体规划(Roadmap) +# FIG 补全引擎总体规划(最终架构直达版) -> 基线:`codex/ssh/fig-wave1-base`(main `0da8be89` + wave-1 契约 `19579413` + 本组文档)。 -> 上游方案:`docs/FIG_AUTOCOMPLETE_INTEGRATION_PLAN.zh-CN.md`(§ 编号引用均指该文档)。 -> 本文档是波次间的唯一规划入口;单波次细则见各 lane 文档。 +> 2026-09-28 指令:**放弃历史包袱,按最终目标实施**。本文取代原 wave-1 过渡路线; +> 方案全文 `docs/FIG_AUTOCOMPLETE_INTEGRATION_PLAN.zh-CN.md`(§ 引用指该文档)。 +> 基线:`codex/ssh/fig-wave1-base`。 -## 0. 总原则(继承自方案,不重复论证) +## 0. 方案定性(一句话) -- Desktop OS 与 Completion Target OS 解耦:generator 只在目标机执行(§1)。 -- 不搬 Fig UI、不在 Rust 重写 parser、不把 generator 跑在桌面机(§49)。 -- 编辑操作由 parser/resolver 产生,UI 只执行(§5.2)。 -- 补全任何一层失败不得影响 PTY 输入链路(§43)。 +**集成 amazon-q-developer-cli 的 autocomplete parser(Fig 补全方案的开源引擎,MIT OR Apache-2.0)+ withfig/autocomplete 全量语料,配对成本插件的结构化补全引擎;Fig 的宿主形态(UI / figterm / 专有运行时)由本插件的 xterm overlay + Rust sidecar + `completion/execute` 替代。** -## 1. 波次划分 +spec 数据与 parser 引擎是配对资产,二者都要、不改其语义;被抛弃的只有 Fig 的宿主形态和本插件自己的过渡层。 -### Wave 0(已完成) +## 1. 取消项(历史包袱,不再做) -- fig-base worktree/分支建立;基线验证绿(typecheck / 128 文件 1284 用例 / build)。 -- 冻结契约类型:`frontend/src/lib/completion/core/types.ts`、`host/protocol.ts`。 -- 契约:`docs/FIG_WAVE1_CONTRACT.zh-CN.md`。 +| 取消 | 原因 | +|---|---| +| LegacyCompletionProvider 包装 + golden parity 零回归层 | 过渡脚手架;最终架构以 fig 引擎为唯一结构化补全来源 | +| `lib/completions/**`(spec.ts、12 手写 specs、provider、remoteFsProvider、figImport) | §6.1:不转人工维护的 SpecCommand;无 fig spec 的命令按 §34 pass-through | +| `scripts/import-fig-specs.mjs` | 同上 | +| flag 默认 legacy 的过渡语义 | 默认即 `fig-safe` | +| 手写 normalize + 纯数据 snapshot 断言 | spec 模块允许含函数(generator 声明/自定义代码),运行受安全策略与 host facade 约束(§15/§16) | + +## 2. 批次 -### Wave 1(当前波次,三 lane 并发) +### 批次 1(当前并发) | Lane | 分支 | 交付 | 细则 | |---|---|---|---| -| A 前端核心 | `codex/ssh/fig-wave1-frontend-core` | CompletionItem/Edit 内核、键盘所有权纯函数、CompletionController、legacy adapter 零回归接线、feature flag + 设置项(七语) | `FIG_WAVE1_LANE_A_FRONTEND_CORE.zh-CN.md` | -| B Rust Host | `codex/ssh/fig-wave1-completion-host` | `completion/execute` RPC(local + ssh target)、安全策略、PROTOCOL 文档、smoke 脚本 | `FIG_WAVE1_LANE_B_COMPLETION_HOST.zh-CN.md` | -| C Fig 管线 | `codex/ssh/fig-wave1-fig-specs` | fig 运行时类型、静态 adapter、sync/verify 脚本、11 命令 spec snapshot、体积实测报告 | `FIG_WAVE1_LANE_C_FIG_SPECS.zh-CN.md` | - -三 lane 文件归属互斥(契约 §6),合并顺序任意;集成阶段见 `FIG_VERIFICATION.zh-CN.md`。 - -**Wave 1 出口判定**:三 lane 全绿合入集成分支 + 全仓门禁通过 + 验收清单(验证文档 §4)勾完。 - -### Wave 2(依赖 wave 1 全部合入) - -1. **fig provider 接线**:C 的 adapter 包装成 `FigCompletionProvider` 进 controller - provider 链(fig > legacy,§35);feature flag `ssh-completion-engine` 默认值 - 评估切 `fig-safe`。 -2. **声明式 generator 端到端**:`git checkout ` / `kubectl get pods` 经 - B 的 `completion/execute` 打到目标机;stale-guard(revision)+ TTL 缓存 - (§29)+ 取消(§30)+ scheduler 两段渲染(§31:静态先行、动态合并)。 -3. **`completion/listDirectory` RPC** + 文件 provider(local fs / SFTP readdir, - §17)。 -4. **Worker spike(决策门)**:DBX WebView(WKWebView/WebView2)里 - `new Worker(blob/data-url)` 可用性验证;可用则把 engine 迁 Worker - (接口已留 requestId/revision),不可用则永久主线程并记录决策。 -5. **spec 集合扩张**:按 C 的体积报告决定默认集是否扩到全量。 - -### Wave 3 - -- WSL executor(`wsl.exe -d `,路径映射,§14)。 -- custom JS generator compatibility shim(§15 Level 3,host facade)。 -- 全量 spec corpus + legacy specs 退役(§35/§52 Step 13-14)。 -- 任意光标位置补全(§23,行尾→行内)。 -- `completion/environment` RPC(target os/shell/cwd 元信息)。 - -## 2. 关键决策记录(已定,不再重议) - -| # | 决策 | 依据 | -|---|---|---| -| D1 | wave 1 引擎跑主线程,不做 Worker 化 | `frontend/build.mjs` 单文件内联 + 断言唯一 script 标签;parser 为纯同步计算,当前量级无性能需求 | -| D2 | spec 全量静态进 bundle,不做按命令懒加载 chunk | 同上构建约束;体积取舍由 Lane C 实测报告在 wave 2 裁决 | -| D3 | completion 超时(默认 1.2s)在 completion 层竞速实现,不改 `SshRuntime::exec` 的 `clamp(5,300)` 下限;超时走既有 `cancel_exec` 回收 | 避免动共享 exec 语义;契约 §5 | -| D4 | read-only 连接对 `completion/execute` 一律拒绝(含 local 语义对齐:SSH target 才有 read-only 概念) | generator=命令执行,不能绕过只读承诺 | -| D5 | 联网只发生在显式 `pnpm fig:sync`;测试一律离线 fixture | SKILL 红线 + CI 可重复 | -| D6 | wave 1 flag 默认 `legacy`(行为与 HEAD 完全一致);`fig-safe` 值可设但等 wave 2 接线后才生效 | 零回归承诺 | - -## 3. 风险登记 +| A' 前端引擎/退役 | `codex/ssh/fig-wave1-frontend-core` | fig 引擎接线(经 source 接缝)、键盘/编辑内核、CompletionMenu→CompletionItem、设置三态七语、legacy `lib/completions/**` 退役 | `FIG_WAVE1_LANE_A_FRONTEND_CORE.zh-CN.md`(最终架构版) | +| B Rust Host | `codex/ssh/fig-wave1-completion-host` | `completion/execute`(local+ssh)——**与本次调整正交,已在途,按原文继续** | `FIG_WAVE1_LANE_B_COMPLETION_HOST.zh-CN.md` | +| C' parser/语料 | `codex/ssh/fig-wave1-fig-specs` | vendor amazon-q parser 快照 + 全量语料管线 + `FigCompletionSource` 实现 | `FIG_WAVE1_LANE_C_FIG_SPECS.zh-CN.md`(最终架构版) | + +出口:三 lane 全绿 + 集成全仓门禁 + `FIG_VERIFICATION.zh-CN.md` §4 清单。 + +### 批次 2(批次 1 集成后) + +1. 声明式 generator E2E:generator 位置经 HostClient → `completion/execute` 打目标机 + postProcess(§18);scheduler 两段渲染(§31)、TTL 缓存(§29)、取消(§30)。 +2. inline-worker runner:`?worker&inline` + WebView feature-detect + 崩溃重启(§44);宿主不支持则主线程定版(source 接缝已保证可迁移)。 +3. 体积定版:全量 corpus vs Top-N 裁剪(依据 C' 体积报告与 verify 预算,决策 D2')。 + +### 批次 3 + +custom JS generator host facade(§15 L3)、WSL executor(§14)、`completion/environment`、任意光标位置(§23)。 + +## 3. 决策记录(D1/D3/D4/D5 继续有效,以下为修订与新增) + +| # | 决策 | +|---|---| +| D2' | 单文件构建约束保持;spec 全量 bundled(manifest 静态 import);体积由 verify 预算门禁管理,超限裁 allowlist 而非引入 chunk | +| D6' | 设置键 `ssh-completion-engine`:`fig-safe`(默认)/ `fig` / `off`;`ssh-completion-spec` 键退役 | +| D7 | 引擎访问只经 `fig/source.ts` 冻结接缝;worker 化延后不阻塞批次 1 | +| D8 | 语义权威 = vendored amazon-q parser;generator 一律经 `completion/execute` 在目标机执行,前端不直连 shell | +| D9 | `lib/completions/**` 由 Lane A' 删除;`splitCommandLine` 上移为冻结 `core/tokenize.ts` 供 fig source 复用 | + +## 4. 风险登记(更新) | 风险 | 缓解 | |---|---| -| bundle 体积超预期(kubectl/aws 巨型 spec) | C 产出实测报告 + verify 脚本设预算阈值;wave 2 可缩 allowlist | -| DBX WebView 不支持 Worker | wave 2 spike 前不依赖;主线程架构已是兜底形态 | -| completion/execute 被视为命令执行面变更 | agent-flow 规定 human review;B 的 PR 必须人工审后才能合(agent 不自合) | -| 远端 1.2s 超时在高 RTT 链路误杀 | 超时可配(clamp 上限 3s);wave 2 generator 结果有 TTL 缓存,重复触发少 | -| App.vue 接线回归(13789 行单文件) | A 的 golden parity 测试固化 HEAD 行为;键盘语义表驱动单测;UI mock 走查沿用 | +| amazon-q parser 包路径/API 与预期不符 | C' 以快照实际为准,facade 隔离,偏差如实写报告 | +| 全量 corpus bundle 体积 | C' verify 预算门禁 + 裁剪序报告;按 D2' 裁 allowlist | +| parser 的 Node API 依赖(fs/process…) | 编译期剥离 + facade 抛 unsupported → 降级(§43),引擎不可因之崩溃 | +| sandbox 无外网导致 sync 不可执行 | sync 是唯一联网点;失败即 blocker 上报,脚本与测试仍须交付(fixture 验证) | +| B 与 A'/C' 集成时序 | B 的 RPC 批次 1 无前端调用方,generator E2E 在批次 2 接线 | -## 4. 治理 +## 5. 治理(不变) -- agent 不 push / 不 merge / 不安装;集成与发版归 integrator/用户。 -- B 属命令执行面变更,PR 需人工 review(`.github/agent-flow.yml` security 段)。 -- 冻结类型变更只能由协调者统一裁决后同步全 lane。 +agent 不 push / 不 merge / 不安装;B 属命令执行面变更,PR 需人工 review;冻结文件变更由协调者统一裁决后同步全 lane。 diff --git a/docs/FIG_VERIFICATION.zh-CN.md b/docs/FIG_VERIFICATION.zh-CN.md index c4a48c57..a403f43b 100644 --- a/docs/FIG_VERIFICATION.zh-CN.md +++ b/docs/FIG_VERIFICATION.zh-CN.md @@ -1,37 +1,43 @@ -# FIG Wave 1 验证计划 +# FIG 验证计划(最终架构版) > 环境统一:`export PATH="$HOME/.nvm/versions/node/v22.21.0/bin:$HOME/Library/pnpm:$HOME/.cargo/bin:$PATH"` -## 1. 各 lane 交付门禁(agent 完成前自查,全绿才算完成) +## 1. Lane 交付门禁(完成前自查,全绿才算完) -### Lane A / C(前端) +### Lane A'(frontend-core) ```bash -pnpm --dir /frontend install --prefer-offline -pnpm --dir /frontend typecheck -pnpm --dir /frontend test # A:既有 128 文件零失败 + 新增 spec 全过;C:同 + fig 用例 -pnpm --dir /frontend build +pnpm --dir /frontend install --prefer-offline +pnpm --dir /frontend typecheck && pnpm --dir /frontend test && pnpm --dir /frontend build +grep -rn "lib/completions" /frontend/src # 必须无结果 +grep -rn "ssh-completion-spec" /frontend/src # 必须无结果 ``` -C 追加:`pnpm --dir /frontend fig:verify`;sync 幂等性自查一次。 +### Lane C'(fig-specs) -### Lane B(后端) +```bash +pnpm --dir /frontend install --prefer-offline +pnpm --dir /frontend typecheck && pnpm --dir /frontend test && pnpm --dir /frontend build +pnpm --dir /frontend fig:verify +# fig:sync 幂等自查:同 pin 二次运行 diff 为空(有外网时) +``` + +### Lane B(completion-host,原文不变) ```bash -cargo fmt --manifest-path /backend/Cargo.toml --check -cargo clippy --locked --manifest-path /backend/Cargo.toml --all-targets -- -D warnings -cargo test --locked --manifest-path /backend/Cargo.toml -# docker 容器可用时(见 SKILL 测试容器章节): -python3 /scripts/smoke_completion.py +cargo fmt --manifest-path /backend/Cargo.toml --check +cargo clippy --locked --manifest-path /backend/Cargo.toml --all-targets -- -D warnings +cargo test --locked --manifest-path /backend/Cargo.toml +python3 /scripts/smoke_completion.py # docker 容器可用时 ``` -`git -C diff --stat codex/ssh/fig-wave1-base` 必须只落在契约 §6 -归属文件内(B 的 ssh.rs 最小只读查询例外需在报告中列明)。 +### 归属检查(全 lane) + +`git diff --stat codex/ssh/fig-wave1-base` 只落契约 §4 归属文件(B 的 ssh.rs 预批例外须列报告)。 -## 2. 集成阶段(三 lane 合入后,integrator/协调者执行) +## 2. 集成阶段(三 lane 合入,integrator/协调者执行) ```bash -# 集成分支:从 fig-wave1-base 起,依序 merge 三条 lane 分支(文件互斥,顺序任意) git checkout -b codex/ssh/fig-wave1-integration codex/ssh/fig-wave1-base git merge codex/ssh/fig-wave1-frontend-core git merge codex/ssh/fig-wave1-completion-host @@ -43,42 +49,40 @@ python3 scripts/check_vendor_lockstep.py python3 scripts/verify_rdp_vendor_integrity.py node scripts/connection-forms/verify.mjs pnpm --dir frontend typecheck && pnpm --dir frontend test && pnpm --dir frontend build -node scripts/smoke_ui_mock.mjs -node scripts/smoke_ui_settings.mjs -pnpm --dir frontend fig:verify # C 产物在集成态复验 +pnpm --dir frontend fig:verify +node scripts/smoke_ui_mock.mjs && node scripts/smoke_ui_settings.mjs cargo fmt --manifest-path backend/Cargo.toml --check cargo clippy --locked --manifest-path backend/Cargo.toml --all-targets -- -D warnings cargo test --locked --manifest-path backend/Cargo.toml -python3 scripts/smoke_completion.py # 容器可用时 +python3 scripts/smoke_completion.py # 容器可用时 + +# 退役门禁 +grep -rn "lib/completions\|ssh-completion-spec" frontend/src # 无结果 +git grep -l "import-fig-specs" # 无结果 ``` -## 3. 行为回归红线(任一破坏即回退整改) - -1. **PTY 输入链路零影响**:A 的 golden parity 测试 + 键盘表驱动单测全绿; - completion 任何异常(engine 抛错/存储读失败)不冒泡到 onData 路径。 -2. **默认行为 = HEAD**:无 `ssh-completion-engine` 存储时,浮层/键盘/接受 - 行为与基线逐项一致(手动清单:git ch、git checkout - 进值层、 - hint 行 Tab 透传、Enter 恒执行、Esc 关闭、总开关关→零浮层)。 -3. **既有 ssh/exec 语义零改动**:B 分支 `git diff codex/ssh/fig-wave1-base -- backend/src/ssh.rs backend/src/exec.rs` - 除预批的最小只读查询 fn 外为空。 -4. **零新增运行时依赖**:`frontend/package.json` dependencies 与 - `backend/Cargo.toml` [dependencies] 无新增项(scripts/devDeps 变更需报告获批)。 - -## 4. Wave 1 验收清单(对齐方案 §51 的 wave-1 子集) - -- [ ] CompletionItem/CompletionEdit/EditBuffer 落地且 legacy 零回归(A) -- [ ] 键盘所有权规则表驱动固化,Enter/hint-Tab 透传不可回归(A) -- [ ] feature flag `ssh-completion-engine` + 设置项七语(A) -- [ ] `completion/execute` local/SSH 双 target、超时/上限/取消/只读拒绝(B) -- [ ] PROTOCOL 文档 + smoke 用例(SKIP 语义正确)(B) -- [ ] 11 spec snapshot + manifest + verify 脚本(C) -- [ ] 体积实测报告与 wave 2 默认集建议(C) -- [ ] 三 lane 全绿 + 集成全仓门禁全绿 -- [ ] lane 报告齐备(契约 §8 格式:base SHA/commits/files/验证/偏差/风险/follow-up) +## 3. 回归红线(任一破坏即回退整改) + +1. **PTY 输入链路零影响**:completion 任何异常(parser 抛错/存储读失败/source 崩)不冒泡到 onData 路径;`off` 时零结构化浮层。 +2. **键盘语义表不回归**:Enter 恒执行当前行;动态/generator 位置与 loading 时 Tab 透传 shell;↑↓/Esc 菜单内消费;表驱动单测固化。 +3. **既有 ssh/exec 语义零改动**:B 分支对 `backend/src/ssh.rs`、`backend/src/exec.rs` 的 diff 除预批最小只读查询 fn 外为空。 +4. **零新增运行时依赖**:`frontend/package.json` dependencies 与 `backend/Cargo.toml` [dependencies] 无新增(C' devDeps 论证制获批除外)。 +5. **历史建议 / ghost 不受影响**:独立引擎,行为与基线一致。 + +## 4. 批次 1 验收清单 + +- [ ] fig 引擎(vendored amazon-q parser + 全量 manifest)经 `FigCompletionSource` 接缝驱动浮层(别名/嵌套/`--`/flag=value 语义来自 parser) +- [ ] legacy `lib/completions/**` 退役,两个 grep 门禁通过 +- [ ] 键盘所有权规则表驱动固化(Enter / 动态 Tab 透传不可回归) +- [ ] `completion/execute` local/SSH 双 target(超时/上限/取消/只读拒绝) +- [ ] PROTOCOL 文档 + smoke(SKIP 语义正确) +- [ ] snapshot 双 pin + verify 预算门禁 + 体积报告(全量 vs Top-N 建议) +- [ ] 设置 `ssh-completion-engine` 三态 + 七语;`ssh-completion-spec` 退役 +- [ ] 三 lane 全绿 + 集成全仓门禁全绿 + lane 报告齐备(契约 §6 格式) ## 5. 回滚策略 -- 每 lane 独立分支,任一 lane 失败可单独弃置,不影响其余两条。 -- B/C 合入后默认不改变任何用户可见行为(flag 默认 legacy;completion/execute - 无调用方),可安全随版本携带;A 是唯一行为敏感面,靠 parity 测试兜底。 +- lane 独立分支,任一失败可单独弃置。 +- C' 产物在批次 2 前无运行时调用方,为零风险携带;B 的 RPC 同理。 +- A' 是唯一行为敏感面(App.vue/键盘),靠表驱动单测 + §3 红线 + 手动清单兜底。 - 集成分支问题 → 弃集成分支重做,不动 fig-wave1-base 与各 lane 分支。 diff --git a/docs/FIG_WAVE1_CONTRACT.zh-CN.md b/docs/FIG_WAVE1_CONTRACT.zh-CN.md index 8a0f107e..e6274bae 100644 --- a/docs/FIG_WAVE1_CONTRACT.zh-CN.md +++ b/docs/FIG_WAVE1_CONTRACT.zh-CN.md @@ -1,98 +1,59 @@ -# FIG 补全引擎 Wave 1 实施契约 +# FIG 补全引擎实施契约(最终架构版) -> 协调者:主会话。基线分支:`codex/ssh/fig-wave1-base`(= main `0da8be89`,已含 -> fix-120 #120 系列)。方案全文:`docs/FIG_AUTOCOMPLETE_INTEGRATION_PLAN.zh-CN.md`。 -> 三个实施 lane 在各自 worktree/分支并发实施;分支合入由 integrator/用户决定, -> agent 不得自行 merge / push / 安装插件。 +> 基线 `codex/ssh/fig-wave1-base`;方案全文 `docs/FIG_AUTOCOMPLETE_INTEGRATION_PLAN.zh-CN.md`。 +> 本版按「放弃历史包袱、直达最终架构」指令修订:**集成 amazon-q-developer-cli 的 +> autocomplete parser(Fig 开源引擎)+ withfig/autocomplete 全量语料,legacy +> `lib/completions/` 退役**。lane 细则:LANE_A(最终架构版)/ LANE_B(不变)/ +> LANE_C(最终架构版);验证:FIG_VERIFICATION。 -## 1. Wave 1 范围 +## 1. 冻结文件(只 import 不改;变更须协调者裁决) -| Lane | 分支 | 范围(对应方案里程碑) | -|---|---|---| -| A 前端核心 | `codex/ssh/fig-wave1-frontend-core` | M1 + M2-lite:core 纯函数(edit/ranking)、Legacy adapter、CompletionController、键盘所有权模块 + 单测、App.vue 接线、feature flag | -| B Rust Host | `codex/ssh/fig-wave1-completion-host` | M4-lite:`backend/src/completion/*`,仅 `completion/execute`(local + ssh target),PROTOCOL 文档,smoke 脚本 | -| C Fig 管线 | `codex/ssh/fig-wave1-fig-specs` | M3 地基:fig/types + adapter(静态子集)、sync/verify 脚本、体积实测报告、vendor LICENSE/NOTICE | +- `frontend/src/lib/completion/core/types.ts`(CompletionItem/Edit/BufferState/Response…) +- `frontend/src/lib/completion/core/tokenize.ts`(splitCommandLine,自 legacy spec.ts 上移) +- `frontend/src/lib/completion/host/protocol.ts`(completion/execute 线协议) +- `frontend/src/lib/completion/fig/source.ts`(FigCompletionSource 接缝:A 面向它编码,C 实现它) -## 2. 非目标(wave 2+,本波次禁止实施) +## 2. 总线决策 -- **Worker 化**:构建管线 `frontend/build.mjs` 用 `inlineDynamicImports: true` 产出 - 单文件自包含 index.html(并断言恰好 1 个 script 标签)。引擎 wave 1 跑主线程; - 接口保留 requestId/revision 字段为将来迁移留位。 -- **按命令懒加载 chunk**:同一构建约束下不可行;spec 全量进 bundle,体积是否 - 可接受由 Lane C 的实测报告定夺(wave 2 决策输入)。 -- WSL executor;declarative generator 与 UI 打通(等 B 合入后 wave 2 做 - `git checkout ` E2E);文件 provider 升级(沿用 fix-120 已有的 - `remoteFsProvider` 缓存版思路);custom JS generator / loadSpec。 +1. **revision 纪律**:异步结果须 `revision` + `sessionId`(+requestId)匹配,否则静默丢弃(§5.1/§30/§43)。 +2. **键盘所有权**(§21,`keyboard.ts` 纯函数 + 表驱动单测固化): -## 3. 冻结契约 - -`frontend/src/lib/completion/core/types.ts` 与 `frontend/src/lib/completion/host/protocol.ts` -是本波次的冻结类型。实现方**只 import,不修改**;确需变更 → 写进 lane 报告, -由协调者统一裁决后再同步给所有 lane。 - -## 4. 总线决策 - -1. **revision 纪律**:一切异步结果(provider/generator)回来时必须校验 - `revision` 与 `sessionId` 都匹配当前 buffer,否则静默丢弃(方案 §5.1/§30/§43)。 -2. **键盘所有权**(方案 §21,必须以纯函数 + 单测固化,放 - `frontend/src/lib/completion/keyboard.ts`): - - | 状态 | Enter | Tab | ↑↓ | Esc | + | 菜单状态 | Enter | Tab | ↑↓ | Esc | |---|---|---|---|---| - | 菜单开 + 静态候选 | 放行 shell | accept | 移动 | 关闭 | - | 菜单开 + 动态 hint / loading | 放行 shell | 放行 shell | 移动 | 关闭 | - | 菜单关 | 放行 shell | 放行 shell | shell | — | + | 静态候选 | 放行 shell | accept | 移动 | 关闭 | + | 动态/generator 位置或 loading | 放行 shell | 放行 shell | 移动 | 关闭 | + | 关闭 | shell | shell | shell | — | 菜单显示 ≠ 键盘所有权;补全任何一层失败不得影响 PTY 输入链路。 -3. **feature flag**:pluginStore key `ssh-completion-engine`,值 - `"legacy" | "fig-safe"`,wave 1 默认 `"legacy"`(fig-safe 等 C 的 spec 落地后 - wave 2 再切默认)。SettingsDialog 增加引擎选择;新文案七语全补 - (zh-CN/zh-TW/en/es/it/ja/pt)。 -4. **accept 范围**:wave 1 仅行尾补全(`cursor === text.length`);任意光标位置 - 留 wave 2(方案 §22/§23)。 -5. **RPC 命名**:方法名 `completion/execute`;字段 camelCase(仓库协议约定, - 见 SKILL 与 `docs/PROTOCOL.zh-CN.md`);错误沿用 sidecar 字符串 Err 惯例, - 加 `completion:` 前缀分类。 - -## 5. completion/execute RPC 契约(与 host/protocol.ts 一致) +3. **设置**:`ssh-completion-engine` ∈ {`fig-safe`(默认), `fig`, `off`};`ssh-completion-spec` 退役;新文案七语(zh-CN/zh-TW/en/es/it/ja/pt)。 +4. **accept 范围**:仅行尾补全(`cursor === text.length`);任意光标批次 3(§22/§23)。 +5. **RPC 命名**:`completion/execute`;camelCase;错误为字符串 Err、`completion:` 前缀。 +6. **语义权威** = vendored amazon-q parser(别名/persistent/variadic/嵌套/`--` 不自研);generator 一律经 `completion/execute` 目标机执行(批次 2 接线),前端不直连 shell。 +7. **legacy 退役**:`lib/completions/**` 删除;无 spec 命中 → pass-through(§34),不造假候选。 -约束: +## 3. completion/execute 契约(与 host/protocol.ts 一致) -- generator 一律 `sudo=false`;只读连接(read_only)直接拒绝; -- `timeoutMs` 在 completion 层 clamp 到 [200, 3000],默认 1200; -- stdout/stderr 各自按 `maxOutputBytes` 截断并置 `truncated=true`; -- SSH 实现复用 `SshRuntime::exec`(`backend/src/ssh.rs` 约 :3638),带 exec_id; - completion 层竞速超时,超时后走既有 `ssh/exec/cancel` 同路径回收 - (**不修改** `SshRuntime::exec` 现有 `clamp(5,300)` 下限); -- local 实现用短生命周期子进程(`std::process::Command` + 超时杀进程), - **禁止**注入用户交互 PTY(`backend/src/local_terminal.rs` 的 PTY 与本功能无关); -- 审计与 `ssh/exec` 同模式(`backend/src/main.rs` 现有 audit 调用照搬); -- wave 1 不做 `completion/listDirectory`、`completion/environment`。 +- target `{kind:"local"|"ssh", sessionId}`;`timeoutMs` clamp [200,3000] 默认 1200;`maxOutputBytes` 默认 256KiB;`mode:"completion-generator"`。 +- sudo 恒 false;只读连接拒绝;超时 completion 层竞速 + `cancel_exec` 回收;**不修改** `SshRuntime::exec` 的 `clamp(5,300)`。 +- local 用短生命周期子进程(tokio),禁止注入交互 PTY;审计对齐 `ssh/exec`。 +- 批次 1 不做 `completion/listDirectory` / `completion/environment` / WSL。 -## 6. 文件归属(越界即冲突,禁止) +## 4. 文件归属(越界即冲突) -| Lane | 拥有(新增/修改) | +| Lane | 拥有(新增/修改/删除) | |---|---| -| A | `frontend/src/lib/completion/core/{engine,edit,ranking}.ts`、`frontend/src/lib/completion/keyboard.ts`、`frontend/src/lib/completion/legacy/*`、`frontend/src/lib/completion/CompletionController.ts`;`frontend/src/App.vue`;`frontend/src/components/CompletionMenu.vue`(仅必要 props 适配);`frontend/src/lib/i18n.ts`;`frontend/src/components/SettingsDialog.vue`;对应 `*.spec.ts` | -| B | `backend/src/completion/*`;`backend/src/main.rs`(仅路由注册与 audit 接线);`docs/PROTOCOL.zh-CN.md`;`scripts/smoke_completion.py`;模块内 `#[cfg(test)]` | -| C | `frontend/src/lib/completion/fig/*`;`frontend/vendor/*`;`scripts/sync_fig_specs.mjs`;`scripts/verify_fig_specs.mjs`;`frontend/package.json`(仅 scripts 与必要 devDependencies);`docs/fig-specs-size-report.md`;对应 `*.spec.ts` | -| 只读共享 | `core/types.ts`、`host/protocol.ts`、`lib/completions/spec.ts`(legacy parser,A 经 adapter 包装、不改语义)、`lib/completions/provider.ts`、`lib/overlayPlacement.ts`、`backend/src/{ssh,exec,local_terminal}.rs`(B 只调用不重构) | - -## 7. 验证门禁(交付前必须全绿) +| A' | `lib/completion/core/{edit,ranking}.ts`、`lib/completion/keyboard.ts`、`CompletionController.ts`;`App.vue`;`components/CompletionMenu.vue(+spec)`;`lib/i18n.ts`;`components/SettingsDialog.vue`;`lib/pluginStore.ts`(键位 swap)与 `pluginStorage.spec.ts` 相应更新;**`lib/completions/**` 删除**;对应 `*.spec.ts` | +| B | `backend/src/completion/*`;`backend/src/main.rs`(路由+audit 接线);`backend/src/ssh.rs`(仅预批最小只读查询 fn,报告列明);`docs/PROTOCOL.zh-CN.md`;`scripts/smoke_completion.py` | +| C' | `lib/completion/fig/**`(`source.ts` 除外);`frontend/vendor/**`;`scripts/sync_fig_specs.mjs`、`scripts/verify_fig_specs.mjs`、`scripts/import-fig-specs.mjs` 删除;`frontend/package.json`(scripts + devDeps 论证制);`tsconfig.json`(如需);`docs/fig-specs-size-report.md` | -环境:`export PATH="$HOME/.nvm/versions/node/v22.21.0/bin:$HOME/Library/pnpm:$HOME/.cargo/bin:$PATH"` +## 5. 门禁 -- A/C:`pnpm --dir frontend install --prefer-offline` → `typecheck` → `test` → `build` -- B:`cargo fmt --manifest-path backend/Cargo.toml --check` → - `cargo clippy --locked --manifest-path backend/Cargo.toml --all-targets -- -D warnings` → - `cargo test --locked --manifest-path backend/Cargo.toml` -- 全 lane:不新增运行时依赖(SKILL 红线;C 的 devDependency 例外需在报告里论证); - 不使用真实 SSH 凭据;单测不得联网(C 的上游拉取只发生在显式 sync 命令,测试用 - fixture);不 push / 不 merge / 不安装插件。 +- A':`pnpm --dir frontend install --prefer-offline` → typecheck → test → build; + `grep -rn "lib/completions" frontend/src` 与 `grep -rn "ssh-completion-spec" frontend/src` 均无结果。 +- C':前端三件套 + `pnpm --dir frontend fig:verify` + `fig:sync` 幂等自查。 +- B:`cargo fmt --check` / `clippy --locked --all-targets -- -D warnings` / `test --locked`;docker 可用时 `smoke_completion.py`。 +- 全 lane:`git diff --stat codex/ssh/fig-wave1-base` 只落 §4 归属文件;零新增运行时依赖(C' build-time devDeps 论证制);测试零联网;不 push / 不 merge / 不安装。 -## 8. 提交与移交 +## 6. 提交与移交 -- 每 lane 在自己分支按逻辑单元提交(zh conventional commits,如 - `feat(completion): ...`);**不 push**。 -- lane 最终报告必须包含:base SHA、commit 列表、变更文件、验证结果摘要、 - 与契约的偏差、风险与 follow-up。 +zh conventional commits(`feat(completion): …`);lane 报告含 base SHA、commit 列表、变更文件、验证摘要、与细则偏差、风险、follow-up。 diff --git a/docs/FIG_WAVE1_LANE_A_FRONTEND_CORE.zh-CN.md b/docs/FIG_WAVE1_LANE_A_FRONTEND_CORE.zh-CN.md index 6cc3b706..57a33d59 100644 --- a/docs/FIG_WAVE1_LANE_A_FRONTEND_CORE.zh-CN.md +++ b/docs/FIG_WAVE1_LANE_A_FRONTEND_CORE.zh-CN.md @@ -1,137 +1,56 @@ -# Lane A 细则:前端补全核心(frontend-core) +# Lane A 细则(最终架构版):fig 引擎接线 + legacy 退役 > 分支 `codex/ssh/fig-wave1-frontend-core`,基线 `codex/ssh/fig-wave1-base`。 -> 先读:`FIG_WAVE1_CONTRACT.zh-CN.md`、`FIG_ROADMAP.zh-CN.md`、`FIG_VERIFICATION.zh-CN.md`。 -> 冻结类型 `frontend/src/lib/completion/core/types.ts` 只 import 不改。 +> 冻结接口(只 import):`core/types.ts`、`core/tokenize.ts`、`fig/source.ts`。 +> 旧版细则中的 legacySpecAdapter / golden parity 章节**作废**;其余签名与 App.vue +> 锚点继续有效。先读:契约(最终架构版)、ROADMAP、FIG_VERIFICATION。 ## 1. 目标 / 非目标 -目标:把补全的「解析→候选→键盘→接受」从 App.vue 收进可测试的模块层, -行为与 HEAD **零回归**;为 wave 2 的 fig provider / generator 接线留好插槽。 - -非目标:Worker 化、fig spec 接线、动态 provider 行为变更、overlay/定位改动、 -`lib/completions/spec.ts`(legacy parser)语义改动、build.mjs。 - -## 2. 新增文件与签名 - -### `frontend/src/lib/completion/core/edit.ts` - -```ts -export interface AppliedEdit { text: string; cursor: number } -/** 把 CompletionEdit 应用到行文本;cursorOffset 缺省 = edit.text.length。 */ -export function applyEditToText(text: string, edit: CompletionEdit): AppliedEdit -/** 行尾 token 替换的 edit 构造(legacy adapter 用;addSpace 时 text 尾补空格)。 */ -export function trailingTokenEdit(text: string, token: string, addSpace: boolean): CompletionEdit -``` - -### `frontend/src/lib/completion/core/ranking.ts` - -```ts -export const MAX_COMPLETION_ITEMS = 20; -/** score 降序、同分 label 字典序、截断;纯函数,引擎唯一排序出口。 */ -export function rankItems(items: CompletionItem[]): CompletionItem[] -``` - -### `frontend/src/lib/completion/keyboard.ts`(契约 §4.2 的固化) - -```ts -export interface CompletionKeyboardState { - menuOpen: boolean; - hasItems: boolean; - /** 高亮项 kind:null=无高亮;"hint"=动态占位行(Tab 透传)。 */ - activeItemKind: CompletionItemKind | null; - loading: boolean; -} -export type CompletionKeyAction = "accept" | "passthrough" | "next" | "prev" | "close" | "none"; -export function resolveCompletionKey(state: CompletionKeyboardState, key: string): CompletionKeyAction -``` - -规则(必须表驱动单测全覆盖,缺一不可): - -| menuOpen | activeItemKind | Enter | Tab | ArrowUp/Down | Escape | -|---|---|---|---|---|---| -| true | subcommand/option/argument/… | passthrough | accept | next/prev | close | -| true | hint(或 hasItems=false / loading) | passthrough | passthrough | next/prev(仅 hasItems) | close | -| false | — | passthrough | passthrough | passthrough | none | - -### `frontend/src/lib/completion/legacy/legacySpecAdapter.ts` - -把 `matchSpecLine(line, COMPLETION_SPECS)` 包装成引擎 resolver: - -```ts -export interface LegacyResolveInput { line: string; requestId: number; revision: number; sessionId: string } -export function legacyResolve(input: LegacyResolveInput): CompletionResponse -``` - -映射规则(**逐字段保真,零回归的根**): - -- `SpecMatch.rows[].kind`:`sub→subcommand`、`flag→option`、`value→argument`、`hint→hint`。 -- `edit` = `{ text: row.token + (row.space ? " " : ""), replaceStart: match.replaceStart, replaceEnd: match.replaceEnd }`(fig-base 的 `SpecMatch` 已带精确边界,见 `lib/completions/spec.ts` `SpecMatch` 定义)。 -- `label/description/score` 原样;`source: "legacy-spec"`;`id` 用 `legacy:{commandPath}:{label}:{i}` 稳定串。 -- `context`:`command = commandPath[0] ?? null`,`tokenStart=match.replaceStart`,`tokenEnd=match.replaceEnd`。 -- `matchSpecLine` 返回 null → `state: "pass-through"`、`items: []`(回落历史建议浮层,由 App.vue 现有逻辑处理)。 -- rows 空(spec 命中无候选)同样 `pass-through`。 - -### `frontend/src/lib/completion/CompletionController.ts` - -```ts -export interface CompletionControllerOptions { - sessionId: () => string; - readLine: () => string; // 返回 pendingTerminalInput 当前值 - enabled: () => boolean; // 总开关 + 引擎开关合成后的判定 - debounceMs?: number; // 默认 90 - onResponse: (response: CompletionResponse) => void; - onAcceptEdit: (edit: CompletionEdit) => void; // App.vue 执行终端写入 -} -export class CompletionController { - /** App.vue 在行缓冲每个变更点调用:revision++ 并调度 request("typing")。 */ - lineChanged(): void; - request(trigger: CompletionTrigger): void; - accept(item: CompletionItem): void; - dismiss(): void; - /** 会话切换:重置 revision/requestId,丢弃在途结果(sessionId guard)。 */ - resetSession(): void; -} -``` - -纪律(单测必须覆盖): - -1. 响应回来时 `requestId`、`revision`、`sessionId` 三者任一不匹配当前态 → 静默丢弃。 -2. `resolve` 全程 try/catch;任何异常 → `state:"pass-through"` 空响应,绝不抛到调用方(PTY 红线)。 -3. debounce 期间的多次 `lineChanged` 只发一次请求。 -4. `enabled()===false` → 直接 pass-through,不调度。 - -wave 1 的 resolver 就是 `legacyResolve`;provider 链(fig)留 wave 2,不在本 lane 实现。 - -## 3. App.vue 接线(锚点为 fig-base 行号,允许 ±小漂移,以函数名为准) - -| 位置 | 改造 | -|---|---| -| `COMPLETION_SPEC_ENABLED_KEY` ≈L913 / `completionSpecEnabled()` ≈L925 | 保留总开关;新增 `COMPLETION_ENGINE_KEY = "ssh-completion-engine"`,读值 `legacy`(默认)/`fig-safe`,wave 1 两种值都走 legacy resolver | -| `openCompletionMenu(match)` ≈L940 | 改为消费 `CompletionResponse`:items 映射进现有 `completionRows/Level/CommandPath/ActiveIndex/Anchor` refs(Level 由 context+activeKind 推导,保持现有三层展示语义) | -| `handleCompletionKey(event)` ≈L995 | 改为:构造 `CompletionKeyboardState` → `resolveCompletionKey` → 按 action 执行(accept 走 `controller.accept`;passthrough 返回 false;close `closeCompletionMenu`)。**Enter 恒放行、hint 行 Tab 放行的现语义必须保持**(由键盘单测背书) | -| `acceptCompletionRow(row)` ≈L1030 | 改为 `controller.accept(item)` → `onAcceptEdit(edit)` → `applyEditToText(pendingTerminalInput, edit)` → 沿用 `replaceTerminalLineWith(nextLine, false)`(整行擦重打的现机制不动)→ `controller.lineChanged()` 刷新 | -| `refreshCompletionMenu()` ≈L1050 / `refreshSuggestionsAfterInput()` ≈L3069 | 内层的 `matchSpecLine` 直调替换为 `controller.request("manual"/"typing")`;历史建议/ghost 分支**一行不动** | -| `trackPendingInput` ≈L3020 / `replaceTerminalLineWith` ≈L3230 / Enter/Ctrl+C 清行点 / ghost 接受点 | 每处行缓冲变更后补 `controller.lineChanged()`(一行调用,不改既有逻辑) | -| 会话切换/关闭 | `controller.resetSession()` | - -## 4. 设置项与 i18n - -- `frontend/src/lib/pluginStore.ts`:`PLUGIN_STORE_KEYS` 追加 `"ssh-completion-engine"`(本 lane 唯一允许改此文件的一行;B/C 不碰它)。 -- `SettingsDialog.vue`:在现有 `ssh-completion-spec` 开关(≈L270)旁加引擎 Select(reka-ui wrapper,参照同文件既有 Select 用法):`legacy` / `fig-safe`;`fig-safe` 项描述注明「wave 2 生效」。 -- `i18n.ts`:新增 key(如 `settings.completion.engine`、`.engineLegacy`、`.engineFigSafe`、`.engineHint`)七语全补(zh-CN/zh-TW/en/es/it/ja/pt)。 - -## 5. 测试清单(`*.spec.ts` 同目录) - -- `edit.spec.ts`:trailingTokenEdit 边界(尾空格/空行=纯插入点、引号 token、`--flag=val`);applyEditToText cursorOffset。 -- `ranking.spec.ts`:排序确定性、截断 20。 -- `keyboard.spec.ts`:§2 表全组合(≥10 用例)。 -- `legacySpecAdapter.spec.ts`:**golden parity**——对现有 `spec.spec.ts` 语料 + specs/index 全量 spec,断言 controller 输出与 `matchSpecLine` 直查在 label/kind/顺序/描述上逐一相等。 -- `CompletionController.spec.ts`:三重 guard(revision/requestId/sessionId)、debounce 合并、异常降级 pass-through、accept→onAcceptEdit 的 edit 正确、enabled=false。 -- 既有 `spec.spec.ts` / `CompletionMenu.spec.ts` 必须零修改通过。 - -## 6. 验收 - -1. `pnpm --dir frontend typecheck && pnpm --dir frontend test && pnpm --dir frontend build` 全绿。 -2. 手动清单(UI mock 或 dev):`git ch` 填充、`git checkout -` 进值层、hint 行 Tab 透传、Enter 恒执行、Esc 关闭、总开关关闭后零浮层——与 HEAD 行为一致。 -3. 默认路径(无 `ssh-completion-engine` 存储)行为与 HEAD 完全一致(parity 测试背书)。 +目标:fig 引擎(经 `FigCompletionSource` 接缝)成为结构化补全唯一来源;键盘/编辑 +内核模块化;legacy `lib/completions/**` 整体退役;设置三态。 + +非目标:parser/manifest 实现(Lane C')、generator 执行接线(批次 2)、worker +runner(批次 2)、overlay/定位改动、ghost 与历史建议(独立引擎,不动)。 + +## 2. 交付物 + +### 2.1 内核(签名沿用,仍有效) + +- `core/edit.ts`:`applyEditToText(text, edit): {text, cursor}`、`trailingTokenEdit(text, token, addSpace): CompletionEdit`。 +- `core/ranking.ts`:`rankItems(items)`(MAX_COMPLETION_ITEMS=20,score 降序 + label 字典序,纯函数)。 +- `keyboard.ts`:`CompletionKeyboardState` + `resolveCompletionKey(state, key)`;契约 §2.2 表全组合表驱动单测(≥10 用例)。`activeItemKind` 语义:静态候选=accept;`hint`/loading/空=Tab passthrough;Enter 恒 passthrough。 +- `CompletionController.ts`:`lineChanged/request/accept/dismiss/resetSession`;构造参数 `sessionId()/readLine()/enabled()/debounceMs?(默认90)/onResponse/onAcceptEdit`;纪律:三重 guard(revision+sessionId+requestId)、debounce 合并、异常降级 pass-through、`enabled()===false` 直接 pass-through。 +- resolver = `FigCompletionSource`(注入构造)。本 lane 提供 `FakeFigCompletionSource`(测试用);真实实现由 Lane C' 在集成分支接入。 + +### 2.2 legacy 退役(本 lane 独有删除权) + +- 删除 `frontend/src/lib/completions/` **整目录**(spec.ts、specs/*、provider.ts、remoteFsProvider.ts、figImport.ts 及全部 *.spec.ts)。 +- 清除 `App.vue`、`SettingsDialog.vue`、`pluginStore.ts`、`pluginStorage.spec.ts` 中 `ssh-completion-spec` 的一切引用。 +- 门禁:`grep -rn "lib/completions" frontend/src` 与 `grep -rn "ssh-completion-spec" frontend/src` 均无结果。 + +### 2.3 fig 引擎接线 + +- App.vue:删除 `matchSpecLine / COMPLETION_SPECS / CompletionRow` 依赖;completion refs 迁 controller + `CompletionResponse`。 +- 接线点(锚点=函数名,与旧版 §3 表一致):`openCompletionMenu / handleCompletionKey / acceptCompletionRow / refreshCompletionMenu / trackPendingInput / replaceTerminalLineWith / refreshSuggestionsAfterInput`、Enter/Ctrl+C 清行点、ghost 接受点、会话切换(`resetSession`)。 +- source 返回 null(无命中 / generator 动态位置)→ pass-through:菜单关、Tab 交 shell(§34,与旧 hint 行 UX 等价)。 +- `CompletionMenu.vue`:props 迁移为 `items: CompletionItem[]` + `activeIndex` + anchor + viewport;emit `accept(item)` / `activate(index)`(方案 §24 映射:label/description/kind 直用,接受回传 `item.edit`);同步更新 `CompletionMenu.spec.ts`。App.vue 侧把 item.edit 经 `applyEditToText` 应用后仍走 `replaceTerminalLineWith(nextLine, false)`(整行擦重打机制不变)。 + +### 2.4 设置与 i18n + +- `pluginStore.ts`:删 `ssh-completion-spec`,增 `ssh-completion-engine`(`"fig-safe" | "fig" | "off"`,默认 `"fig-safe"`)。 +- `SettingsDialog.vue`:结构化补全开关改为引擎 Select(reka-ui wrapper,参照同文件既有 Select 用法);`off` = 无结构化浮层(历史/ghost 不受影响);`fig` 与 `fig-safe` 批次 1 行为相同(差异自 generator 接线起),选项描述注明。 +- `i18n.ts`:新增文案七语全补。 + +## 3. 测试 + +- edit / ranking / keyboard / controller 单测(旧版 §5 清单去掉 parity 项)。 +- FakeFigCompletionSource 驱动 controller 全路径:ready / pass-through(null) / stale 丢弃 / 异常吞掉 / enabled=false / debounce 合并 / accept→onAcceptEdit 边界正确。 +- `CompletionMenu.spec.ts` 更新为 items props。 +- 既有其余测试零回归(删除 legacy 目录连带其 spec 文件属预期,不计回归)。 + +## 4. 验收 + +1. `pnpm --dir frontend typecheck / test / build` 全绿。 +2. §2.2 两个 grep 门禁通过。 +3. 手动清单(dev + fake source;真实数据冒烟在集成分支做):`git ch` 静态候选、`git co` 别名命中(fake 模拟)、无命中命令 Tab 透传、Enter 恒执行、Esc 关闭、`off` 全关、历史/ghost 不受影响。 diff --git a/docs/FIG_WAVE1_LANE_B_COMPLETION_HOST.zh-CN.md b/docs/FIG_WAVE1_LANE_B_COMPLETION_HOST.zh-CN.md index 92c96df6..34f754b9 100644 --- a/docs/FIG_WAVE1_LANE_B_COMPLETION_HOST.zh-CN.md +++ b/docs/FIG_WAVE1_LANE_B_COMPLETION_HOST.zh-CN.md @@ -3,6 +3,10 @@ > 分支 `codex/ssh/fig-wave1-completion-host`,基线 `codex/ssh/fig-wave1-base`。 > 先读:`FIG_WAVE1_CONTRACT.zh-CN.md`(§5 RPC 契约)、`FIG_ROADMAP.zh-CN.md`、`FIG_VERIFICATION.zh-CN.md`。 > 前端线协议 `frontend/src/lib/completion/host/protocol.ts` 是冻结镜像,两边字段必须逐字一致。 +> +> 2026-09-28 注:最终架构调整(vendor amazon-q parser + legacy 退役)与本 lane +> **正交**——`completion/execute` 正是最终架构的 generator 执行通道。本文继续 +> 有效,在途 agent 按原文执行,勿受其他 lane 文档修订影响。 ## 1. 目标 / 非目标 diff --git a/docs/FIG_WAVE1_LANE_C_FIG_SPECS.zh-CN.md b/docs/FIG_WAVE1_LANE_C_FIG_SPECS.zh-CN.md index a87eef3e..5e1ea8dc 100644 --- a/docs/FIG_WAVE1_LANE_C_FIG_SPECS.zh-CN.md +++ b/docs/FIG_WAVE1_LANE_C_FIG_SPECS.zh-CN.md @@ -1,115 +1,64 @@ -# Lane C 细则:Fig Spec 管线(fig-specs) +# Lane C 细则(最终架构版):vendored parser + 全量语料 + source 实现 > 分支 `codex/ssh/fig-wave1-fig-specs`,基线 `codex/ssh/fig-wave1-base`。 -> 先读:`FIG_WAVE1_CONTRACT.zh-CN.md`、`FIG_ROADMAP.zh-CN.md`(决策 D2/D5)、`FIG_VERIFICATION.zh-CN.md`。 -> 冻结类型 `core/types.ts` 只 import 不改;**不碰 App.vue**(接线是 wave 2)。 - -## 1. 目标 / 非目标 - -目标:建立「上游 spec → 归一化 snapshot → 静态 adapter」的 build-time 管线, -产出 11 个命令的 spec bundle 与体积实测报告,为 wave 2 接线备料。 - -非目标:把 fig provider 接进 controller/设置、generator 执行(B lane + wave 2)、 -custom JS generator、全量 corpus、修改 `lib/completions/` 下任何既有文件 -(`figImport.ts` 保留原样,新代码放 `lib/completion/fig/`)。 - -## 2. 目录 - -```text -frontend/src/lib/completion/fig/ -├── types.ts # 归一化后的 fig 运行时类型(见 §3) -├── normalize.ts # 上游原始 spec 对象 → 归一化(纯函数,sync 脚本与测试共用) -├── adapter.ts # resolveFigLine:归一化 spec → CompletionItem[](纯函数) -└── fixtures/git.fig.json # 手工裁剪的稳定 git spec 快照(测试离线用) -frontend/vendor/fig-specs/ -├── LICENSE / NOTICE.fig.txt -├── snapshot.json # {source, commit, generatedAt, formatVersion:1, sizes} -├── build/.ts # 归一化 spec 数据模块(纯数据,import type 引类型) -└── spec-manifest.generated.ts # 静态 import map(不做懒加载,决策 D2) -scripts/sync_fig_specs.mjs # 唯一联网点(决策 D5) -scripts/verify_fig_specs.mjs # 离线校验 -``` - -已核实:`frontend/vendor/` 不受 `check_vendor_lockstep.py`(只管 backend/vendor -RDP 链)与 `validate_repo.py`(固定 standalone 路径清单)约束。 - -## 3. `types.ts`(归一化形态,非上游原始形态) - -```ts -export interface FigGeneratorDecl { kind: "script"; script: string[]; splitOn?: string } // wave1 只存声明不执行 -export interface FigArg { name?: string; description?: string; isVariadic?: boolean; isOptional?: boolean; suggestions?: string[]; generators?: FigGeneratorDecl[] } -export interface FigOption { names: string[]; description?: string; args?: FigArg | null; isRepeatable?: boolean; isPersistent?: boolean; isRequired?: boolean } -export interface FigSubcommand { name: string; aliases?: string[]; description?: string; subcommands?: FigSubcommand[]; options?: FigOption[]; args?: FigArg[] } -export interface FigSpecRoot { name: string; aliases?: string[]; description?: string; subcommands?: FigSubcommand[]; options?: FigOption[]; args?: FigArg[] } -``` - -设计要点:`Option.name: string | string[]` 归一为 `names: string[]`(含长/短 -名原样,`--` 前缀保留);`isPersistent` 保留并在 adapter 里沿子命令树下传; -函数型 generator 只留 `{kind:"script", script}` 声明,`postProcess` 等 JS 函数 -**丢弃**(wave 2 用 B 的 RPC + 前端 postProcess 兜底,snapshot 不存代码)。 - -## 4. `normalize.ts` - -输入:node 直接 import 上游 `src/.ts` 得到的默认导出(多数是纯对象; -个别含函数/模板——函数字段按 §3 规则丢弃或降级)。输出:`FigSpecRoot`。 -规则:别名数组保留;`args` 取首个 required 之外的可选链(`isOptional` 标记); -子命令树不截深度(legacy 的两层限制不适用于 fig 路线);`loadSpec`/ -`generateSpec` 指令 → 记录为该子命令 `generators: []` + 保留原节点(wave 3 处理)。 - -## 5. `adapter.ts` - -```ts -export interface FigResolveResult { items: CompletionItem[]; context: CompletionContext } -export function resolveFigLine(line: string, specs: readonly FigSpecRoot[]): FigResolveResult | null -``` - -- 复用 `lib/completions/spec.ts` 的 `splitCommandLine`(只读 import)。 -- 能力必须超出 legacy:别名命中(`git co` → checkout)、persistent options 沿树下传、 - variadic args(多个位置 token 持续补)、repeatable option 不因已出现而消失、 - 子命令树无深度限制、`--` 终结、`--flag=value` 内联值层。 -- `edit` 用与 legacy adapter 相同的行尾 token 边界语义(replaceStart/replaceEnd); - `source: "fig-spec"`;无命中 → null(调用方回落 legacy → 历史)。 -- 测试全走 `fixtures/git.fig.json`(离线):alias、persistent、variadic、`--`、 - inline `=`、深度子命令 ≥8 个用例;另加一个「fig 与 legacy 对 git 同行输入 - 候选对比」的信息性用例(允许 fig 更丰富,断言 fig ⊇ legacy 的静态部分)。 - -## 6. `scripts/sync_fig_specs.mjs`(唯一联网点) - -1. `FIG_AUTOCOMPLETE_REF`(缺省用 snapshot.json 已记录 pin;首次为当前默认 pin, - 落盘新 pin)clone/fetch `withfig/autocomplete` 到 `.tmp/`(脚本自清)。 -2. allowlist(wave 1,方案 §36 M3):git, docker, kubectl, helm, npm, pnpm, - yarn, ssh, aws, cargo, systemctl。 -3. node ≥22.18 原生 type-stripping `import()` 每个上游 `src/.ts` - (版本不满足直接报错,不静默降级);`normalize` 后 emit - `frontend/vendor/fig-specs/build/.ts`(`import type { FigSpecRoot } from "../../../src/lib/completion/fig/types"` + `export const spec: FigSpecRoot = {...}`)。 -4. 生成 `spec-manifest.generated.ts`(静态 `import` + `Record`) - 与 `snapshot.json`;拷贝上游 LICENSE → `vendor/fig-specs/LICENSE`,写 NOTICE - (含方案 §48 的 attribution 文案)。 -5. 打印每 spec 归一化后 KB 与总量;对上游 spec 若 import 失败(非纯对象), - 记入 snapshot.json 的 `skipped[]` 并警告,不中断其余。 -6. **emit 的模块必须纯数据**:写入前断言序列化结果不含 `"function"`。 - -`scripts/verify_fig_specs.mjs`(离线,CI 可用):manifest↔文件一致、snapshot -pin 存在、LICENSE/NOTICE 存在、纯数据断言、体积预算(单 spec >150KB 或总量 ->600KB → 非零退出;首测后可调阈值,调整写进报告)。 - -`frontend/package.json` scripts 增加:`fig:sync` / `fig:verify` / `fig:test` -(= `vitest run src/lib/completion`)。**不新增运行时依赖**;build-time 依赖 -同样目标为零(type-stripping 足够);若确需 devDependency,报告中论证并给出 -替代方案,未获批前不写入。 - -## 7. 体积实测报告 `docs/fig-specs-size-report.md` - -- 表:每 spec(raw KB / 归一化 KB);总量。 -- bundle 影响:`pnpm build` 前后 `ui/index.html` 字节数(manifest 引入 vs - 临时注释掉 manifest 导出做对照)。 -- 结论建议:wave 2 默认集(若超预算给出裁剪序:aws → kubectl → helm …)。 - -## 8. 验收 - -1. `pnpm fig:sync` 幂等可重跑(同 pin 二次运行 diff 为空)。 -2. `pnpm fig:verify` 通过。 -3. `pnpm --dir frontend typecheck && test && build` 全绿(vendor 数据模块参与 - typecheck/build 不报错)。 -4. 体积报告成文,含明确「wave 2 默认集」建议。 -5. 全程除 `pnpm fig:sync` 外无网络行为;测试零联网。 +> 冻结接口:`fig/source.ts`(**实现它**)、`core/tokenize.ts`(复用)。 +> 旧版细则(手写 normalize / 纯数据断言 / 11 命令 allowlist)**作废**。 +> 先读:契约(最终架构版)、ROADMAP(D2'/D5/D8)、FIG_VERIFICATION。 + +## 1. 快照管线(`scripts/sync_fig_specs.mjs`,唯一联网点) + +1. clone/fetch 并 pin 两个上游(commit 落盘 snapshot.json): + - `withfig/autocomplete` —— spec 语料,**全量**(不再 allowlist)。 + - `aws/amazon-q-developer-cli` —— autocomplete TypeScript parser 子树(包内路径以快照实际为准:先探查仓库结构,找到 fig 兼容 parser/类型所在包再 vendor)。 +2. parser 子树 → `frontend/vendor/amazon-q-autocomplete/`(src + 必要本地依赖 + LICENSE-MIT + LICENSE-APACHE + NOTICE,含方案 §48 attribution 文案)。 +3. 语料 → `frontend/vendor/fig-specs/`: + - `build/.js`:经 bundler 编译的 ESM spec 模块(默认导出 spec 对象;**允许含函数**——generator 声明/自定义代码保留,运行受批次 2 安全策略约束)。 + - `spec-manifest.generated.ts`:静态 import map `Record`(bundled,决策 D2')。 + - `snapshot.json`:`{specs:{repo,commit}, parser:{repo,commit,path}, generatedAt, sizes, skipped[]}`。 + - `LICENSE` / `NOTICE.fig.txt`。 +4. 编译器:优先复用 vite JS API(已是 devDep,零新增依赖);确需 esbuild 等 devDep → 报告论证,未获批不写入。 +5. Node ≥22.18 原生 type-stripping 直接 import 上游 TS;版本不足硬失败。 +6. 逐 spec 编译,失败的记 `skipped[]` 继续;打印体积表。 +7. **幂等**:同 pin 二次运行 diff 为空。 + +## 2. parser 编译产物 + +- `frontend/vendor/autocomplete-engine/parser.js`:单 ESM、browser-safe。 +- Node API(fs/process/path…)依赖剥离或封装为可注入 facade:不可注入时抛 `unsupported` → 上层降级(§43),引擎不得崩溃。 +- `loadSpec` / `generateSpec` 类文件系统语义 → 由 manifest 替代或禁用,处理清单写进报告。 + +## 3. source 实现(`frontend/src/lib/completion/fig/figCompletionSource.ts`) + +- 实现冻结接口 `FigCompletionSource`: + - tokenize 用 `core/tokenize.ts`(不自研)。 + - 语义全走 vendored parser(别名/persistent/variadic/嵌套/`--`/`--flag=value`)。 + - 产出 `CompletionResponse`:items 带 `CompletionEdit`(行尾 token 边界 replaceStart/replaceEnd)、`source:"fig-spec"`、kind 映射(subcommand/option/argument/hint…)。 + - generator 声明位置批次 1 → 返回 null(pass-through;批次 2 经 `completion/execute` 接入)。 + - 任何异常吞掉返 null,绝不上抛。 +- parser.js 无类型:本文件内最小 `declare` + 防御性收敛,不污染全局命名空间。 + +## 4. verify 与预算(`scripts/verify_fig_specs.mjs`,离线) + +- manifest ↔ build 文件一致;双 pin 存在;LICENSE/NOTICE 齐全;parser 产物 import 冒烟通过。 +- 体积预算:语料总量默认上限 5MB、单 spec 300KB(首测后可调,调整写报告);超限非零退出并按体积降序列裁剪建议序。 +- `spec-manifest.generated.ts` 与 build 模块参与 typecheck/build 必须通过。 + +## 5. 测试(全离线) + +- parser 冒烟:bundled parser + git spec 解析(别名 / 嵌套子命令 / `--`)。 +- `figCompletionSource`:fixture manifest → CompletionResponse 断言(含 edit 边界、pass-through 分支、异常吞掉、generator 位置返 null)。 +- 旧 normalize / 纯数据断言测试删除。 + +## 6. package.json / 杂项 + +- scripts:`fig:sync` / `fig:verify` / `fig:test`(vitest run src/lib/completion)。 +- 删除 `scripts/import-fig-specs.mjs`(`figImport.ts` 本体属 Lane A' 的 `lib/completions/` 清理)。 +- `tsconfig.json` 调整(vendor 排除/包含)允许,报告说明。 + +## 7. 验收 + +1. `fig:sync` 幂等 + `fig:verify` 通过。 +2. `pnpm --dir frontend typecheck / test / build` 全绿。 +3. `docs/fig-specs-size-report.md`:全量体积表 + 「全量 vs Top-N」批次 2 建议 + skipped 清单。 +4. 除 `fig:sync` 外零联网;测试零联网。 +5. sandbox 无外网时:sync 无法执行即 **blocker 如实上报**;脚本与测试仍须交付并以 fixture 验证。 diff --git a/frontend/src/lib/completion/core/tokenize.spec.ts b/frontend/src/lib/completion/core/tokenize.spec.ts new file mode 100644 index 00000000..1747d2a3 --- /dev/null +++ b/frontend/src/lib/completion/core/tokenize.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { splitCommandLine } from "./tokenize"; + +// 冻结语义回归:splitCommandLine 自 lib/completions/spec.ts 上移(最终架构 +// legacy 退役后的公共依赖),本文件保证语义与原实现逐字一致。 +describe("splitCommandLine(冻结)", () => { + it("普通空白切分", () => { + const r = splitCommandLine("git commit -m msg"); + expect(r.tokens.map((t) => t.text)).toEqual(["git", "commit", "-m", "msg"]); + expect(r.trailingSpace).toBe(false); + expect(r.terminated).toBe(false); + }); + + it("尾随空白 = 正在敲空 token(空行 trailingSpace 为 false,空前缀由 partial 兜底)", () => { + expect(splitCommandLine("git ").trailingSpace).toBe(true); + expect(splitCommandLine("git").trailingSpace).toBe(false); + expect(splitCommandLine("").tokens).toEqual([]); + expect(splitCommandLine("").trailingSpace).toBe(false); + }); + + it("单引号内空格不切分、全字面", () => { + const r = splitCommandLine("echo 'a b c'"); + expect(r.tokens[1]).toMatchObject({ text: "a b c", quoted: true }); + }); + + it("双引号保留空格、支持转义", () => { + expect(splitCommandLine('echo "a\\ b"').tokens[1]?.text).toBe("a b"); + }); + + it("引号外反斜杠转义", () => { + expect(splitCommandLine("echo a\\ b").tokens[1]?.text).toBe("a b"); + }); + + it("显式空 token ''", () => { + expect(splitCommandLine("git ''").tokens[1]).toMatchObject({ text: "", quoted: true }); + }); + + it("裸 -- 终结符关闭 flag 解析(终结符本身入列)", () => { + const r = splitCommandLine("git log -- --foo"); + expect(r.terminated).toBe(true); + expect(r.tokens[2]).toMatchObject({ text: "--", isFlag: false, terminator: true }); + expect(r.tokens[3]).toMatchObject({ text: "--foo", isFlag: false }); + }); + + it("行尾悬空反斜杠按字面保留", () => { + expect(splitCommandLine("echo x\\").tokens[1]?.text).toBe("x\\"); + }); + + it("flag 判定(- 与裸 -- 除外)", () => { + expect(splitCommandLine("cmd -x --yy -").tokens.slice(1).map((t) => t.isFlag)).toEqual([true, true, false]); + }); +}); diff --git a/frontend/src/lib/completion/core/tokenize.ts b/frontend/src/lib/completion/core/tokenize.ts new file mode 100644 index 00000000..cd2d7dcd --- /dev/null +++ b/frontend/src/lib/completion/core/tokenize.ts @@ -0,0 +1,114 @@ +// 命令行 token 切分(冻结):自 lib/completions/spec.ts 上移,作为 legacy +// spec 目录退役后的公共依赖(fig source 与 controller 共用)。语义与原实现 +// 逐字一致:空白分隔;单引号内全字面;双引号与引号外支持反斜杠转义; +// 裸 "--" 是 flag 终结符;空 token 只有显式 '' 会产生。 + +export interface CommandToken { + /** 语义内容(引号已剥、转义已解)。 */ + text: string; + /** 是否 flag token(以 - 开头、非裸 "--"、且未被 -- 终结符关闭)。 */ + isFlag: boolean; + /** token 是否整体(或部分)处于引号内——引号内空格不切分。 */ + quoted: boolean; + /** 裸 "--" 终结符:本身不参与候选,仅对后续 token 关闭 flag 解析。 */ + terminator: boolean; +} + +export interface SplitCommandLineResult { + tokens: CommandToken[]; + /** 行尾是裸空白(引号外):当前正在敲一个空 token。 */ + trailingSpace: boolean; + /** 已出现裸 "--" 终结符:其后 token 一律不算 flag。 */ + terminated: boolean; +} + +export function splitCommandLine(line: string): SplitCommandLineResult { + const tokens: CommandToken[] = []; + let current: { text: string; quoted: boolean } | null = null; + let trailingSpace = false; + let terminated = false; + + const pushCurrent = () => { + if (!current) return; + if (current.text === "--" && !current.quoted && !terminated) { + tokens.push({ text: current.text, isFlag: false, quoted: false, terminator: true }); + terminated = true; + } else { + tokens.push({ text: current.text, isFlag: !terminated && current.text.startsWith("-") && current.text !== "-", quoted: current.quoted, terminator: false }); + } + current = null; + }; + + let inSingle = false; + let inDouble = false; + let escaped = false; + for (const char of line) { + if (inSingle) { + if (char === "'") { + inSingle = false; + } else { + current ??= { text: "", quoted: false }; + current.text += char; + current.quoted = true; + } + continue; + } + if (escaped) { + current ??= { text: "", quoted: false }; + current.text += char; + current.quoted = true; + escaped = false; + continue; + } + if (inDouble) { + if (char === "\\") { + escaped = true; + current ??= { text: "", quoted: true }; + current.quoted = true; + } else if (char === '"') { + inDouble = false; + if (current) current.quoted = true; + } else if (char === " " || char === "\t") { + current ??= { text: "", quoted: true }; + current.text += char; + current.quoted = true; + } else { + current ??= { text: "", quoted: true }; + current.text += char; + } + continue; + } + if (char === "'") { + current ??= { text: "", quoted: false }; + current.quoted = true; + inSingle = true; + continue; + } + if (char === '"') { + current ??= { text: "", quoted: false }; + current.quoted = true; + inDouble = true; + continue; + } + if (char === "\\") { + current ??= { text: "", quoted: false }; + current.quoted = true; + escaped = true; + continue; + } + if (char === " " || char === "\t") { + pushCurrent(); + trailingSpace = true; + continue; + } + current ??= { text: "", quoted: false }; + current.text += char; + trailingSpace = false; + } + if (escaped) { + current ??= { text: "", quoted: false }; + current.text += "\\"; + } + pushCurrent(); + return { tokens, trailingSpace, terminated }; +} diff --git a/frontend/src/lib/completion/fig/source.ts b/frontend/src/lib/completion/fig/source.ts new file mode 100644 index 00000000..143d9967 --- /dev/null +++ b/frontend/src/lib/completion/fig/source.ts @@ -0,0 +1,30 @@ +// FIG 补全引擎最终架构的冻结接缝:Lane A(engine-runner / UI / controller) +// 与 Lane C(vendored Amazon Q parser + 全量 spec manifest)之间的唯一边界。 +// C 提供实现;A 面向接口编码,单测用 fake。generator(声明式/自定义)的异步 +// 补全在批次 2 经独立通道接入,本接口保持同步;generator 声明位置 resolve +// 返回 null(调用方 pass-through,Tab 交还 shell)。 + +import type { CompletionResponse } from "../core/types"; + +/** 上游 Fig.Spec 原始对象(含函数字段)。对 Lane A 保持不透明。 */ +export type FigSpecRaw = unknown; + +/** spec-manifest.generated.ts 的冻结形态:bundled 静态 import(决策 D2)。 */ +export type FigSpecManifest = Readonly>; + +export interface FigSourceRequest { + line: string; + requestId: number; + revision: number; + sessionId: string; + trigger: "typing" | "tab" | "manual"; +} + +/** + * 无 spec 命中、parser 抛错、或仅 generator 可补的动态位置 → 返回 null + * (调用方 pass-through)。实现内部必须吞掉一切异常,绝不向上抛。 + */ +export interface FigCompletionSource { + readonly id: string; + resolve(request: FigSourceRequest): CompletionResponse | null; +} From e8c0c893685df44250c5d45a9861e75c621aa1c2 Mon Sep 17 00:00:00 2001 From: jinpy666 Date: Mon, 28 Sep 2026 14:07:38 +0800 Subject: [PATCH 04/22] =?UTF-8?q?feat(completion):=20sidecar=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20completion/execute=E2=80=94=E2=80=94local/ssh=20?= =?UTF-8?q?=E5=8F=8C=20target=20=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIG wave-1 lane B(M4-lite):backend/src/completion/{mod,protocol,security, executor,local,ssh} 新模块 + main.rs 路由臂(仅此一处接线)。 - protocol.rs 与冻结镜像 frontend/.../host/protocol.ts 逐字段一致 (serde camelCase + tag=kind lowercase),round-trip 测试固化; - security.rs:mode 白名单(completion-generator)、command/args/cwd 的 NUL 与数量校验、timeoutMs clamp [200,3000](缺省 1200)、 maxOutputBytes clamp ≤256KiB;远端拼装复用既有 exec::shell_quote 逐参数转义; - local.rs:短生命周期 tokio 子进程(argv 直 exec、stdin /dev/null、 kill_on_drop),stdout/stderr 并发读取各自截断、任一到顶立即杀进程, completion 层竞速超时;不触碰 local_terminal 的交互 PTY; - ssh.rs:read-only 门(决策 D4,经 ssh.rs 预批最小只读查询 fn)、 execId=completion-、竞速超时(决策 D3)后走既有 cancel_exec 回收,不改 SshRuntime::exec 内部 clamp(5,300);exec 合并流 output 显式映射到 stdout、stderr 恒空; - 错误统一 completion: 前缀;零新增依赖,Cargo.lock 未动。 --- backend/src/completion/executor.rs | 68 ++++++++ backend/src/completion/local.rs | 268 +++++++++++++++++++++++++++++ backend/src/completion/mod.rs | 13 ++ backend/src/completion/protocol.rs | 163 ++++++++++++++++++ backend/src/completion/security.rs | 238 +++++++++++++++++++++++++ backend/src/completion/ssh.rs | 132 ++++++++++++++ backend/src/main.rs | 14 ++ backend/src/ssh.rs | 8 + 8 files changed, 904 insertions(+) create mode 100644 backend/src/completion/executor.rs create mode 100644 backend/src/completion/local.rs create mode 100644 backend/src/completion/mod.rs create mode 100644 backend/src/completion/protocol.rs create mode 100644 backend/src/completion/security.rs create mode 100644 backend/src/completion/ssh.rs diff --git a/backend/src/completion/executor.rs b/backend/src/completion/executor.rs new file mode 100644 index 00000000..1f3b0460 --- /dev/null +++ b/backend/src/completion/executor.rs @@ -0,0 +1,68 @@ +//! `completion/execute` 的统一分派入口(供 `main.rs` 路由臂调用)。 +//! +//! 按 [`crate::completion::protocol::CompletionTarget::kind`] 分派:local +//! 走短生命周期子进程,ssh 复用 `SshRuntime::exec`。错误统一 +//! `Result<_, String>`(sidecar 字符串 Err 惯例,`completion:` 前缀)。 + +use crate::completion::local; +use crate::completion::protocol::{ + CompletionExecuteRequest, CompletionExecuteResult, CompletionTarget, +}; +use crate::completion::ssh; +use crate::ssh::SshRuntime; + +/// 执行一个已通过 +/// [`crate::completion::security::validate_and_clamp`] 的请求。`runtime` +/// 仅在 ssh target 下使用(local 分支不触碰任何连接状态)。 +pub async fn dispatch( + runtime: &SshRuntime, + req: &CompletionExecuteRequest, +) -> Result { + match &req.target { + // local 的 session_id 在 wave-1 仅标识发起方(为将来 environment/ + // cwd 解析留位),不参与执行。 + CompletionTarget::Local { .. } => { + local::execute( + &req.command, + &req.args, + req.cwd.as_deref(), + req.timeout_ms, + req.max_output_bytes, + ) + .await + } + CompletionTarget::Ssh { session_id } => ssh::execute(runtime, session_id, req).await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn local_request(command: &str, args: &[&str]) -> CompletionExecuteRequest { + let mut req: CompletionExecuteRequest = serde_json::from_value(json!({ + "target": { "kind": "local", "sessionId": "wb-1" }, + "command": command, + "args": args, + "mode": "completion-generator", + })) + .unwrap(); + crate::completion::security::validate_and_clamp(&mut req).unwrap(); + req + } + + #[cfg(unix)] + #[tokio::test] + async fn dispatches_local_target_end_to_end() { + // SshRuntime::new 只建目录不联网;local 分支不触碰它。 + let dir = tempfile::tempdir().unwrap(); + let runtime = SshRuntime::new(dir.path().to_path_buf()); + let result = dispatch(&runtime, &local_request("printf", &["dispatched"])) + .await + .unwrap(); + assert_eq!(result.exit_code, Some(0)); + assert_eq!(result.stdout, "dispatched"); + assert!(!result.timed_out); + } +} diff --git a/backend/src/completion/local.rs b/backend/src/completion/local.rs new file mode 100644 index 00000000..a9fdcec5 --- /dev/null +++ b/backend/src/completion/local.rs @@ -0,0 +1,268 @@ +//! local target 执行器:短生命周期 tokio 子进程。 +//! +//! argv 直 exec 不经 shell(Windows 无需引号处理),绝不触碰 +//! [`crate::local_terminal`] 的交互 PTY。stdout/stderr **并发**读取且各自按 +//! `max_output_bytes` 上限截断——任一流到顶立即杀进程(停止排空后写端会 +//! 永久阻塞在管道上,另一路的 EOF 也只能靠进程退出到来);completion 层 +//! 用 [`tokio::time::timeout`] 竞速超时,超时杀进程并置 `timed_out`。 + +use std::process::Stdio; +use std::time::Duration; + +use tokio::io::AsyncReadExt; + +use crate::completion::protocol::CompletionExecuteResult; + +/// 执行一次 local generator 命令。入参必须是经过 +/// [`crate::completion::security::validate_and_clamp`] 收紧后的值。 +pub async fn execute( + command: &str, + args: &[String], + cwd: Option<&str>, + timeout_ms: u64, + max_output_bytes: usize, +) -> Result { + let mut builder = tokio::process::Command::new(command); + builder + .args(args) + // generator 是非交互命令:stdin 接 /dev/null,防止误读宿主输入。 + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(dir) = cwd { + builder.current_dir(dir); + } + let mut child = builder + .spawn() + .map_err(|error| format!("completion: failed to start '{command}': {error}"))?; + let mut stdout = child.stdout.take(); + let mut stderr = child.stderr.take(); + + let run = async { + let mut out_buf: Vec = Vec::new(); + let mut err_buf: Vec = Vec::new(); + let mut truncated = false; + let mut out_open = stdout.is_some(); + let mut err_open = stderr.is_some(); + let mut out_chunk = [0u8; 8192]; + let mut err_chunk = [0u8; 8192]; + while out_open || err_open { + tokio::select! { + read = read_step(stdout.as_mut(), &mut out_chunk), if out_open => { + match read { + Ok(0) => out_open = false, + Ok(n) => { + if !append_capped(&mut out_buf, &out_chunk[..n], max_output_bytes) { + out_open = false; + truncated = true; + let _ = child.start_kill(); + } + } + Err(_) => out_open = false, + } + } + read = read_step(stderr.as_mut(), &mut err_chunk), if err_open => { + match read { + Ok(0) => err_open = false, + Ok(n) => { + if !append_capped(&mut err_buf, &err_chunk[..n], max_output_bytes) { + err_open = false; + truncated = true; + let _ = child.start_kill(); + } + } + Err(_) => err_open = false, + } + } + } + } + let status = child.wait().await; + (out_buf, err_buf, truncated, status) + }; + + match tokio::time::timeout(Duration::from_millis(timeout_ms), run).await { + Ok((out, err, truncated, Ok(status))) => Ok(CompletionExecuteResult { + exit_code: status.code(), + stdout: String::from_utf8_lossy(&out).into_owned(), + stderr: String::from_utf8_lossy(&err).into_owned(), + truncated, + timed_out: false, + }), + Ok((_, _, _, Err(error))) => Err(format!("completion: local process failed: {error}")), + Err(_elapsed) => { + // 竞速超时(决策 D3):杀掉并收尸,输出按契约丢弃、exitCode 置空。 + let _ = child.start_kill(); + let _ = child.wait().await; + Ok(CompletionExecuteResult { + timed_out: true, + ..CompletionExecuteResult::default() + }) + } + } +} + +/// 读一步(None 视作已关闭,直接给 EOF),供 select! 两路复用。 +async fn read_step(reader: Option<&mut R>, chunk: &mut [u8]) -> std::io::Result +where + R: tokio::io::AsyncRead + Unpin + ?Sized, +{ + match reader { + Some(reader) => reader.read(chunk).await, + None => Ok(0), + } +} + +/// 追加至多到 `cap`;返回 false 表示缓冲已到顶(调用方停止读该流并杀进程)。 +fn append_capped(buffer: &mut Vec, data: &[u8], cap: usize) -> bool { + let room = cap.saturating_sub(buffer.len()); + if data.len() > room { + buffer.extend_from_slice(&data[..room]); + return false; + } + buffer.extend_from_slice(data); + buffer.len() < cap +} + +#[cfg(test)] +mod tests { + use super::*; + + // 大输出/超时用例用 unix 的 yes/sleep;Windows 本机没有这些工具, + // 跳过(细则 §3 平台注意)。CI 与开发机均为 unix。 + + #[cfg(unix)] + #[tokio::test] + async fn local_printf_succeeds_with_exit_code() { + let result = execute("printf", &["hello".to_string()], None, 1200, 4096) + .await + .unwrap(); + assert_eq!(result.exit_code, Some(0)); + assert_eq!(result.stdout, "hello"); + assert_eq!(result.stderr, ""); + assert!(!result.truncated && !result.timed_out); + } + + #[cfg(unix)] + #[tokio::test] + async fn local_captures_nonzero_exit_and_stderr() { + let result = execute( + "sh", + &["-c".to_string(), "echo boom >&2; exit 3".to_string()], + None, + 1200, + 4096, + ) + .await + .unwrap(); + assert_eq!(result.exit_code, Some(3)); + assert_eq!(result.stderr.trim(), "boom"); + assert!(!result.timed_out); + } + + #[cfg(unix)] + #[tokio::test] + async fn local_runs_in_cwd() { + // /tmp 在 macOS 是 /private/tmp 的符号链接,pwd 打印解析后路径, + // 用 tempdir 双侧 canonicalize 比较才跨平台稳定。 + let dir = tempfile::tempdir().unwrap(); + let dir_str = dir.path().to_str().unwrap().to_string(); + let result = execute("pwd", &[], Some(&dir_str), 1200, 4096) + .await + .unwrap(); + assert_eq!(result.exit_code, Some(0)); + let expected = std::fs::canonicalize(dir.path()).unwrap(); + assert_eq!( + std::path::Path::new(result.stdout.trim()), + expected.as_path(), + "pwd={}", + result.stdout + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn local_timeout_kills_and_reports() { + let started = std::time::Instant::now(); + let result = execute("sleep", &["5".to_string()], None, 400, 4096) + .await + .unwrap(); + assert!(result.timed_out); + assert_eq!(result.exit_code, None); + assert_eq!(result.stdout, ""); + // 400ms 超时必须真正生效(留 2s 余量防 CI 抖动)。 + assert!(started.elapsed() < Duration::from_secs(2)); + } + + #[cfg(unix)] + #[tokio::test] + async fn local_output_cap_truncates() { + // yes 无限输出;上限 1 KiB 时必须停止读取并杀进程。 + let started = std::time::Instant::now(); + let result = execute("yes", &["x".to_string()], None, 1200, 1024) + .await + .unwrap(); + assert!(result.truncated); + assert!(!result.timed_out); + assert!(result.stdout.len() <= 1024); + assert!(result.stdout.starts_with("x\n")); + // 截断后进程被回收,不能等到 1.2s 超时才返回。 + assert!(started.elapsed() < Duration::from_millis(1100)); + } + + #[cfg(unix)] + #[tokio::test] + async fn local_stderr_cap_truncates_too() { + // stderr 到顶同样触发截断+回收(两路流对称)。 + let result = execute( + "sh", + &["-c".to_string(), "yes err >&2".to_string()], + None, + 1200, + 512, + ) + .await + .unwrap(); + assert!(result.truncated); + assert!(!result.timed_out); + assert!(result.stderr.len() <= 512); + } + + #[tokio::test] + async fn local_missing_binary_errors_with_prefix() { + let error = execute("dbx-no-such-generator-binary", &[], None, 1200, 4096) + .await + .unwrap_err(); + assert!(error.starts_with("completion: "), "{error}"); + } + + #[tokio::test] + async fn local_missing_cwd_errors_with_prefix() { + let error = execute( + #[cfg(unix)] + "pwd", + #[cfg(windows)] + "cmd", + &[], + Some("/no/such/dbx-completion-dir"), + 1200, + 4096, + ) + .await + .unwrap_err(); + assert!(error.starts_with("completion: "), "{error}"); + } + + #[test] + fn append_capped_stops_at_cap() { + let mut buffer = Vec::new(); + assert!(append_capped(&mut buffer, b"abc", 8)); + assert_eq!(buffer, b"abc"); + // 追加后恰好到顶:本次已放行,下一次才判停。 + assert!(!append_capped(&mut buffer, b"defgh", 8)); + assert_eq!(buffer.len(), 8); + // 超量数据只保留有 room 的前缀。 + assert!(!append_capped(&mut buffer, b"zzz", 8)); + assert_eq!(buffer, b"abcdefgh"); + } +} diff --git a/backend/src/completion/mod.rs b/backend/src/completion/mod.rs new file mode 100644 index 00000000..be47a9d2 --- /dev/null +++ b/backend/src/completion/mod.rs @@ -0,0 +1,13 @@ +//! FIG 补全引擎的 sidecar 侧 CompletionHost(wave-1 lane B)。 +//! +//! 仅 `completion/execute`:generator 命令按 target 分派到本地短生命周期 +//! 子进程([`local`])或既有 `SshRuntime::exec` 通道([`ssh`]),统一超时 +//! 竞速(决策 D3)、输出上限与 read-only 门(决策 D4)。线协议冻结于 +//! `frontend/src/lib/completion/host/protocol.ts`,两侧字段逐字一致,由 +//! [`protocol`] 的 round-trip 测试固化。 + +pub mod executor; +pub mod local; +pub mod protocol; +pub mod security; +pub mod ssh; diff --git a/backend/src/completion/protocol.rs b/backend/src/completion/protocol.rs new file mode 100644 index 00000000..1fb0c5ab --- /dev/null +++ b/backend/src/completion/protocol.rs @@ -0,0 +1,163 @@ +//! `completion/execute` 的线协议 DTO。 +//! +//! 冻结镜像是 `frontend/src/lib/completion/host/protocol.ts`(FIG wave-1 +//! 契约 §3),两边字段必须逐字一致:请求/响应字段 camelCase,target 用 +//! `kind` 标签(internally tagged,小写变体名)。serde round-trip 测试固化 +//! 这一对应,改任一侧前先过协调者裁决。 + +use serde::{Deserialize, Serialize}; + +/// 补全 generator 的执行目标。`kind` 标签区分 local / ssh;`sessionId` +/// 在 local 侧标识发起补全的本地终端会话(wave-1 仅透传不使用),在 ssh +/// 侧是既有 SSH 会话 id。 +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum CompletionTarget { + #[serde(rename_all = "camelCase")] + Local { + /// 冻结线协议字段:wave-1 执行不读取(将来 environment / cwd + /// 解析留位),故单独 allow dead_code。 + #[allow(dead_code)] + session_id: String, + }, + #[serde(rename_all = "camelCase")] + Ssh { session_id: String }, +} + +/// `completion/execute` 请求。`cwd` / `args` / `timeoutMs` / +/// `maxOutputBytes` 带 serde default,缺省时由 +/// [`crate::completion::security::validate_and_clamp`] 补默认并收紧; +/// `mode` 必须显式给出且等于 `"completion-generator"`。 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompletionExecuteRequest { + pub target: CompletionTarget, + pub command: String, + #[serde(default)] + pub args: Vec, + pub cwd: Option, + #[serde(default)] + pub timeout_ms: u64, + #[serde(default)] + pub max_output_bytes: usize, + pub mode: String, +} + +/// `completion/execute` 响应。`timedOut=true` 表示 completion 层竞速超时 +/// (底层执行已尝试取消回收),此时 `exitCode=null`、输出为空。 +#[derive(Debug, Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct CompletionExecuteResult { + pub exit_code: Option, + pub stdout: String, + pub stderr: String, + pub truncated: bool, + pub timed_out: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn deserializes_local_target_request() { + let request: CompletionExecuteRequest = serde_json::from_value(json!({ + "target": { "kind": "local", "sessionId": "wb-local-1" }, + "command": "git", + "args": ["branch", "--list"], + "cwd": "/tmp/repo", + "timeoutMs": 800, + "maxOutputBytes": 4096, + "mode": "completion-generator", + })) + .unwrap(); + assert!(matches!( + &request.target, + CompletionTarget::Local { session_id } if session_id == "wb-local-1" + )); + assert_eq!(request.command, "git"); + assert_eq!(request.args, ["branch", "--list"]); + assert_eq!(request.cwd.as_deref(), Some("/tmp/repo")); + assert_eq!(request.timeout_ms, 800); + assert_eq!(request.max_output_bytes, 4096); + assert_eq!(request.mode, "completion-generator"); + } + + #[test] + fn deserializes_ssh_target_request() { + let request: CompletionExecuteRequest = serde_json::from_value(json!({ + "target": { "kind": "ssh", "sessionId": "ssh-42" }, + "command": "kubectl", + "args": [], + "timeoutMs": 1200, + "maxOutputBytes": 262144, + "mode": "completion-generator", + })) + .unwrap(); + assert!(matches!( + &request.target, + CompletionTarget::Ssh { session_id } if session_id == "ssh-42" + )); + assert!(request.args.is_empty()); + } + + #[test] + fn request_with_absent_optional_fields_parses() { + // cwd/args/timeoutMs/maxOutputBytes 缺省也能解析(serde default), + // 收紧语义交给 security::validate_and_clamp。 + let request: CompletionExecuteRequest = serde_json::from_value(json!({ + "target": { "kind": "local", "sessionId": "wb-1" }, + "command": "git", + "mode": "completion-generator", + })) + .unwrap(); + assert_eq!(request.cwd, None); + assert!(request.args.is_empty()); + assert_eq!(request.timeout_ms, 0); + assert_eq!(request.max_output_bytes, 0); + } + + #[test] + fn unknown_target_kind_is_rejected() { + let error = serde_json::from_value::(json!({ + "target": { "kind": "wsl", "sessionId": "w-1" }, + "command": "git", + "mode": "completion-generator", + })) + .unwrap_err(); + assert!(error.to_string().contains("wsl"), "{error}"); + } + + #[test] + fn missing_mode_is_rejected_at_parse() { + let error = serde_json::from_value::(json!({ + "target": { "kind": "ssh", "sessionId": "s" }, + "command": "git", + })) + .unwrap_err(); + assert!(error.to_string().contains("mode"), "{error}"); + } + + #[test] + fn serializes_result_with_camel_case_fields() { + let result = CompletionExecuteResult { + exit_code: None, + stdout: "main\n".to_string(), + stderr: String::new(), + truncated: true, + timed_out: true, + }; + let value = serde_json::to_value(&result).unwrap(); + assert_eq!( + value, + json!({ + "exitCode": null, + "stdout": "main\n", + "stderr": "", + "truncated": true, + "timedOut": true, + }) + ); + } +} diff --git a/backend/src/completion/security.rs b/backend/src/completion/security.rs new file mode 100644 index 00000000..ca7a250d --- /dev/null +++ b/backend/src/completion/security.rs @@ -0,0 +1,238 @@ +//! `completion/execute` 的安全校验、参数收紧与远端命令拼装。 +//! +//! 错误串统一 `completion:` 前缀(契约 §4.5:sidecar 字符串 Err 惯例加 +//! 前缀分类)。远端拼装必须走既有 [`crate::exec::shell_quote`],逐参数 +//! 单引号转义,args 是唯一可能携带用户输入的面。 + +use crate::completion::protocol::CompletionExecuteRequest; + +/// completion 层超时下限(毫秒)。 +pub const MIN_TIMEOUT_MS: u64 = 200; +/// completion 层超时上限(毫秒)。 +pub const MAX_TIMEOUT_MS: u64 = 3000; +/// 缺省超时(毫秒)。 +pub const DEFAULT_TIMEOUT_MS: u64 = 1200; +/// 单流(stdout / stderr 各自)输出上限。 +pub const MAX_OUTPUT_BYTES: usize = 256 * 1024; +/// args 数量上限。 +pub const MAX_ARGS: usize = 32; + +/// generator 执行的唯一合法 mode(防止普通 RPC 复用本方法)。 +pub const ALLOWED_MODE: &str = "completion-generator"; + +/// 校验并就地收紧请求: +/// +/// - `mode` 必须是 `"completion-generator"`; +/// - `command` 非空且不含 NUL(command 是插件侧 spec 数据给出的程序名, +/// 按细则原文保持原样拼接;用户可输入面在 args,一律 shell_quote); +/// - `args` 数 ≤ [`MAX_ARGS`] 且每个不含 NUL;`cwd`(如有)不含 NUL; +/// - `timeout_ms` 缺省(serde default 0)→ [`DEFAULT_TIMEOUT_MS`],越界 → +/// clamp 到 [`MIN_TIMEOUT_MS`, `MAX_TIMEOUT_MS`]; +/// - `max_output_bytes` 缺省(0)或超过 [`MAX_OUTPUT_BYTES`] → +/// [`MAX_OUTPUT_BYTES`]。 +pub fn validate_and_clamp(req: &mut CompletionExecuteRequest) -> Result<(), String> { + if req.mode != ALLOWED_MODE { + return Err("completion: mode not allowed".to_string()); + } + if req.command.is_empty() || req.command.contains('\0') { + return Err("completion: invalid command".to_string()); + } + if req.args.len() > MAX_ARGS { + return Err(format!("completion: too many args (max {MAX_ARGS})")); + } + if req.args.iter().any(|arg| arg.contains('\0')) { + return Err("completion: invalid arg".to_string()); + } + if req.cwd.as_deref().is_some_and(|cwd| cwd.contains('\0')) { + return Err("completion: invalid cwd".to_string()); + } + if req.timeout_ms == 0 { + req.timeout_ms = DEFAULT_TIMEOUT_MS; + } + req.timeout_ms = req.timeout_ms.clamp(MIN_TIMEOUT_MS, MAX_TIMEOUT_MS); + if req.max_output_bytes == 0 || req.max_output_bytes > MAX_OUTPUT_BYTES { + req.max_output_bytes = MAX_OUTPUT_BYTES; + } + Ok(()) +} + +/// 远端命令行拼装:`command` 原样 + 空格 + `args` 逐个 +/// [`crate::exec::shell_quote`](拒绝注入面)。远端由 `SshRuntime::exec` +/// 经 `exec` 通道直发该行,由远端默认 shell 解释。 +pub fn build_remote_command_line(command: &str, args: &[String]) -> String { + let mut line = + String::with_capacity(command.len() + args.iter().map(|arg| arg.len() + 3).sum::()); + line.push_str(command); + for arg in args { + line.push(' '); + line.push_str(&crate::exec::shell_quote(arg)); + } + line +} + +/// 按 `cap` 字节截断 UTF-8 文本(回退到字符边界),返回 `(截断后文本, +/// 是否截断)`。SSH 路径拿到的是 exec 合并流 String,上限在 completion +/// 层统一施加。 +pub fn truncate_utf8(text: &str, cap: usize) -> (String, bool) { + if text.len() <= cap { + return (text.to_string(), false); + } + let mut end = cap; + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + (text[..end].to_string(), true) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn request(mode: &str, command: &str, args: &[&str]) -> CompletionExecuteRequest { + serde_json::from_value(json!({ + "target": { "kind": "ssh", "sessionId": "s" }, + "command": command, + "args": args, + "timeoutMs": 1200, + "maxOutputBytes": 4096, + "mode": mode, + })) + .unwrap() + } + + #[test] + fn rejects_disallowed_mode() { + let mut req = request("evil", "git", &[]); + assert_eq!( + validate_and_clamp(&mut req), + Err("completion: mode not allowed".to_string()) + ); + } + + #[test] + fn rejects_empty_and_nul_command() { + let mut req = request("completion-generator", "", &[]); + assert_eq!( + validate_and_clamp(&mut req), + Err("completion: invalid command".to_string()) + ); + let mut req = request("completion-generator", "git\0rm", &[]); + assert_eq!( + validate_and_clamp(&mut req), + Err("completion: invalid command".to_string()) + ); + } + + #[test] + fn rejects_too_many_args() { + let args: Vec = (0..33).map(|n| n.to_string()).collect(); + let mut req = serde_json::from_value(json!({ + "target": { "kind": "local", "sessionId": "w" }, + "command": "git", + "args": args, + "mode": "completion-generator", + })) + .unwrap(); + assert_eq!( + validate_and_clamp(&mut req), + Err("completion: too many args (max 32)".to_string()) + ); + } + + #[test] + fn rejects_nul_in_args_and_cwd() { + let mut req = request("completion-generator", "git", &["branch\0"]); + assert_eq!( + validate_and_clamp(&mut req), + Err("completion: invalid arg".to_string()) + ); + let mut req = request("completion-generator", "git", &[]); + req.cwd = Some("/tmp\0evil".to_string()); + assert_eq!( + validate_and_clamp(&mut req), + Err("completion: invalid cwd".to_string()) + ); + } + + #[test] + fn clamps_timeout_ms() { + let mut req = request("completion-generator", "git", &[]); + req.timeout_ms = 0; + validate_and_clamp(&mut req).unwrap(); + assert_eq!(req.timeout_ms, DEFAULT_TIMEOUT_MS); + + req.timeout_ms = 1; + validate_and_clamp(&mut req).unwrap(); + assert_eq!(req.timeout_ms, MIN_TIMEOUT_MS); + + req.timeout_ms = 500_000; + validate_and_clamp(&mut req).unwrap(); + assert_eq!(req.timeout_ms, MAX_TIMEOUT_MS); + + req.timeout_ms = 800; + validate_and_clamp(&mut req).unwrap(); + assert_eq!(req.timeout_ms, 800); + } + + #[test] + fn clamps_max_output_bytes() { + let mut req = request("completion-generator", "git", &[]); + req.max_output_bytes = 0; + validate_and_clamp(&mut req).unwrap(); + assert_eq!(req.max_output_bytes, MAX_OUTPUT_BYTES); + + req.max_output_bytes = MAX_OUTPUT_BYTES + 1; + validate_and_clamp(&mut req).unwrap(); + assert_eq!(req.max_output_bytes, MAX_OUTPUT_BYTES); + + req.max_output_bytes = 1024; + validate_and_clamp(&mut req).unwrap(); + assert_eq!(req.max_output_bytes, 1024); + } + + #[test] + fn builds_bare_command_without_args() { + assert_eq!(build_remote_command_line("git", &[]), "git"); + } + + #[test] + fn quotes_every_arg() { + let args: Vec = ["branch", "--list", "a b"] + .iter() + .map(ToString::to_string) + .collect(); + assert_eq!( + build_remote_command_line("git", &args), + "git 'branch' '--list' 'a b'" + ); + } + + #[test] + fn quotes_embedded_single_quotes_like_shell_quote() { + // exec::shell_quote 用 '\'' 转义内嵌单引号(仓库既有约定)。 + let args = vec!["a b'c".to_string()]; + assert_eq!( + build_remote_command_line("printf", &args), + "printf 'a b'\\''c'" + ); + } + + #[test] + fn quotes_unicode_args_verbatim() { + let args = vec!["分支-ž".to_string()]; + assert_eq!(build_remote_command_line("echo", &args), "echo '分支-ž'"); + } + + #[test] + fn truncate_utf8_passthrough_and_boundary() { + assert_eq!(truncate_utf8("abc", 10), ("abc".to_string(), false)); + assert_eq!(truncate_utf8("abcdef", 3), ("abc".to_string(), true)); + // 截断点落在多字节字符中间时回退到字符边界,不产生非法 UTF-8。 + let (cut, truncated) = truncate_utf8("aéz", 2); + assert_eq!(cut, "a"); + assert!(truncated); + let (cut, _) = truncate_utf8("éz", 1); + assert_eq!(cut, ""); + } +} diff --git a/backend/src/completion/ssh.rs b/backend/src/completion/ssh.rs new file mode 100644 index 00000000..1bf07955 --- /dev/null +++ b/backend/src/completion/ssh.rs @@ -0,0 +1,132 @@ +//! ssh target 执行器:复用既有 [`crate::ssh::SshRuntime::exec`] 通道。 +//! +//! - read-only 门(决策 D4):只读连接上一律拒绝——generator 即命令执行, +//! 不能绕过只读承诺; +//! - sudo 恒 false; +//! - 竞速超时(决策 D3):completion 层 `tokio::time::timeout` 包住 exec, +//! 超时后用同一 execId 走 [`SshRuntime::cancel_exec`] 回收在途任务, +//! 不改 exec 内部的 `clamp(5, 300)` 下限。 + +use std::time::Duration; + +use serde_json::Value; + +use crate::completion::protocol::{CompletionExecuteRequest, CompletionExecuteResult}; +use crate::completion::security; +use crate::ssh::SshRuntime; + +/// 在 SSH 会话 `session_id` 上执行一次 generator 命令。`req` 必须已过 +/// [`security::validate_and_clamp`]。 +pub async fn execute( + ssh: &SshRuntime, + session_id: &str, + req: &CompletionExecuteRequest, +) -> Result { + if ssh + .completion_session_read_only(session_id) + .await + .map_err(|error| format!("completion: {error}"))? + { + return Err("completion: disabled by the read-only connection setting".to_string()); + } + // wave-1 未定义远端 cwd 语义:显式报错而不是悄悄在错误目录执行 + //(git 类 generator 对目录敏感,静默忽略会产生错误结果)。 + if req.cwd.as_deref().is_some_and(|cwd| !cwd.is_empty()) { + return Err("completion: cwd is not supported for ssh targets in wave 1".to_string()); + } + let exec_id = format!("completion-{}", uuid::Uuid::new_v4()); + let command_line = security::build_remote_command_line(&req.command, &req.args); + let exec = ssh.exec(session_id, Some(&exec_id), &command_line, false, None); + match tokio::time::timeout(Duration::from_millis(req.timeout_ms), exec).await { + Ok(Ok(value)) => map_exec_response(&value, req.max_output_bytes), + Ok(Err(error)) => Err(format!("completion: {error}")), + Err(_elapsed) => { + // 回收在途 exec 任务;取消失败(如已自然结束)不影响超时语义。 + let _ = ssh.cancel_exec(&exec_id); + Ok(CompletionExecuteResult { + timed_out: true, + ..CompletionExecuteResult::default() + }) + } + } +} + +/// 映射 `SshRuntime::exec` 的返回 `{"success": true, "output": String, +/// "exitCode": i32}`(见 `SshRuntime::exec_response`)。字段名与 +/// 补全协议不一致,这里做显式映射:`output` 是 exec 通道的 +/// **stdout+stderr 合并流**(`ExecOutcome.output`),映射到 `stdout`、 +/// `stderr` 恒为空;输出上限在 completion 层统一施加(超限置 +/// `truncated`)。 +fn map_exec_response( + value: &Value, + max_output_bytes: usize, +) -> Result { + let output = value.get("output").and_then(Value::as_str).unwrap_or(""); + let exit_code = value + .get("exitCode") + .and_then(Value::as_i64) + .and_then(|code| i32::try_from(code).ok()); + let (stdout, truncated) = security::truncate_utf8(output, max_output_bytes); + Ok(CompletionExecuteResult { + exit_code, + stdout, + // exec 合并流没有 stderr 半边;显式置空并靠本注释与协议文档声明。 + stderr: String::new(), + truncated, + timed_out: false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn exec_response(output: &str, exit_code: i32) -> Value { + json!({ "success": true, "output": output, "exitCode": exit_code }) + } + + #[test] + fn maps_exec_output_and_exit_code() { + let result = map_exec_response(&exec_response("main\ndev\n", 0), 4096).unwrap(); + assert_eq!(result.exit_code, Some(0)); + assert_eq!(result.stdout, "main\ndev\n"); + assert_eq!(result.stderr, ""); + assert!(!result.truncated && !result.timed_out); + } + + #[test] + fn maps_nonzero_exit_code() { + let result = map_exec_response(&exec_response("boom", 127), 4096).unwrap(); + assert_eq!(result.exit_code, Some(127)); + assert_eq!(result.stdout, "boom"); + } + + #[test] + fn applies_output_cap_with_utf8_boundary() { + // "aééé" = 1+2+2+2 字节:cap=3 恰好落在字符边界,得到 "aé"; + // cap=2 落在 é 中间,回退边界得到 "a"。 + let result = map_exec_response(&exec_response("aééé", 0), 3).unwrap(); + assert!(result.truncated); + assert_eq!(result.stdout, "aé"); + let result = map_exec_response(&exec_response("aééé", 0), 2).unwrap(); + assert!(result.truncated); + assert_eq!(result.stdout, "a"); + } + + #[test] + fn tolerant_of_missing_fields() { + let result = map_exec_response(&json!({}), 4096).unwrap(); + assert_eq!(result.exit_code, None); + assert_eq!(result.stdout, ""); + } + + #[test] + fn exec_id_carries_completion_prefix() { + // 固化 execId 前缀约定:与用户手写的 ssh/exec execId 命名空间 + // 区分开,便于排查(真实 uuid 由运行时路径生成)。 + let exec_id = format!("completion-{}", uuid::Uuid::new_v4()); + assert!(exec_id.starts_with("completion-")); + assert!(exec_id.len() > "completion-".len()); + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs index c137575f..e9b50f7e 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -3,6 +3,7 @@ mod agent_terminal; mod alert_triage; mod app_bridge; mod audit_log; +mod completion; mod connection_import; mod docker; mod exec; @@ -442,6 +443,19 @@ impl Plugin { self.ssh.cancel_exec(exec_id)?; Ok(json!({ "success": true })) } + // FIG 补全引擎 generator 执行(wave-1 lane B):反序列化 → + // 安全校验/收紧 → 按 target 分派(local 短子进程 / ssh 复用 + // exec 通道)。审计与 ssh/exec 臂同款:该臂无审计调用,此处 + // 同样不加。 + "completion/execute" => { + let mut request: completion::protocol::CompletionExecuteRequest = parse(params)?; + completion::security::validate_and_clamp(&mut request)?; + let result = self + .runtime + .block_on(completion::executor::dispatch(self.ssh.as_ref(), &request))?; + serde_json::to_value(result) + .map_err(|error| format!("completion: failed to encode result: {error}")) + } "ssh/terminal/resize" => { let session_id = required_string(¶ms, "sessionId")?; let cols = required_u32(¶ms, "cols")?; diff --git a/backend/src/ssh.rs b/backend/src/ssh.rs index 4e301a9c..78bf1be5 100644 --- a/backend/src/ssh.rs +++ b/backend/src/ssh.rs @@ -3477,6 +3477,14 @@ impl SshRuntime { } } + /// Completion-host read-only gate (FIG wave-1 lane B, decision D4): + /// `completion/execute` must refuse SSH targets on read-only + /// connections. Minimal read-only query only — no other behavior of + /// this module changes. + pub async fn completion_session_read_only(&self, session_id: &str) -> Result { + Ok(self.session(session_id).await?.read_only) + } + /// Connection-level sudoers-style allowlist for privileged commands /// (`sudo_whitelist` in the connection's external config). Empty config /// = gate off; otherwise the command (minus a leading `sudo` token) must From 1e33f6795eac7cf6962ce36f9629a7c2bde92a79 Mon Sep 17 00:00:00 2001 From: jinpy666 Date: Mon, 28 Sep 2026 14:07:44 +0800 Subject: [PATCH 05/22] =?UTF-8?q?docs(completion):=20PROTOCOL=20=E5=A2=9E?= =?UTF-8?q?=E8=A1=A5=20completion/execute=20=E5=8D=8F=E8=AE=AE=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RPC 总表加行 + 文末新节(文体对齐 WT-4):参数表(camelCase)、返回 字段、语义要点(target-side 执行、sudo 恒 false、只读拒绝 D4、超时 竞速+取消 D3、双流各自 maxOutputBytes 截断、exec 合并流映射、 completion: 错误前缀、ssh cwd wave-1 显式不支持)。 --- docs/PROTOCOL.zh-CN.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/docs/PROTOCOL.zh-CN.md b/docs/PROTOCOL.zh-CN.md index f9735488..a771cf54 100644 --- a/docs/PROTOCOL.zh-CN.md +++ b/docs/PROTOCOL.zh-CN.md @@ -37,6 +37,7 @@ WezTerm 的 ssh domain 支持 `spawn` 语义:在已认证 transport 上另开 | `ssh/host-key/resolve` | 处理工作台内的主机密钥确认 | | `ssh/exec` | 在会话连接上执行远程命令,可选 Quick Sudo 提权 | | `ssh/exec/cancel` | 中止进行中的远程命令(按 `execId`) | +| `completion/execute` | FIG 补全引擎的 generator 命令执行(local / ssh 双 target、超时竞速、输出上限、只读拒绝,见「completion/execute(补全 generator 执行)」节) | | `ssh/forward/interfaces` | 本机网卡地址探测(供端口映射面板的监听地址选择器):无参 → `{interfaces: [{name, addr, isLoopback}]}`,回环优先、v4 先于 v6、按 IP 去重;探测失败返回空数组(选择器隐藏,手输不受影响)。`if-addrs`(getifaddrs)实现,无会话依赖 | | `ssh/forward/list`、`ssh/forward/start`、`ssh/forward/stop` | 用户级端口映射(ssh(1) -L/-R,见「端口映射」节):`list` 按 `{connectionId?}`/`{sessionId?}` 过滤返回 `{forwards: [row]}`;`start` `{sessionId, kind: "local"\|"remote", listenHost?, listenPort, targetHost, targetPort}`(`listenHost` 缺省 127.0.0.1;`listenPort: 0` 由本机/服务端挑选,`boundPort` 回报实际端口)→ `{forward: row}`;`stop` `{id}` → `{success, forward}`,未知 id 报错。row 字段 camelCase:`id/sessionId/connectionId/kind/listenHost/listenPort/boundPort/targetHost/targetPort/state("starting"\|"active"\|"stopped"\|"error")/error?/connectionsTotal/connectionsActive/bytesUp/bytesDown`。状态迁移发 `ssh/forward/state`(notify)`{id, sessionId, connectionId, state, error?}` | | `ssh/agent/resolve` | 处理 AI 终端同步执行的命令审批(按 `challengeId`,一次性;approve 可携 `command` 编辑后原文与 `remember: true` 记住标记,见「审批记忆」节) | @@ -1049,3 +1050,34 @@ sidecar 启动与偏好写入时同步进程内快速标志(同 `x11_forwardin 则跳过。两种情形均经事件 `ssh/recording/auto` 提示一次,负载 `{ sessionId, recordingId? }` 或 `{ sessionId, skipped: true }`,只含 id 不含内容。Transcript 纯文本导出在前端完成(复用 `ssh/recording/get` 分页 + 既有保存桥,ANSI 剥离/时间戳拼接为纯前端逻辑),不新增协议面。 + +## completion/execute(补全 generator 执行) + +FIG 补全引擎的专用执行通道(wave-1 lane B):把一条 generator 命令执行到**正确的 target 侧**(桌面 OS 与补全目标 OS 解耦——local 目标在 sidecar 所在机器、ssh 目标在远端会话机器),与普通用户 RPC(`ssh/exec`)不耦合,可单独限时、限输出、做安全策略。线协议冻结于 `frontend/src/lib/completion/host/protocol.ts`,字段逐字一致。 + +参数(camelCase): + +- `target`:`{ kind: "local" | "ssh", sessionId }`(internally tagged)。local 的 `sessionId` 标识发起补全的本地终端会话(wave-1 仅透传);ssh 的 `sessionId` 是既有 SSH 会话 id。 +- `command`:generator 程序名(非空、不含 NUL)。 +- `args`:参数数组(≤32 个、每个不含 NUL)。 +- `cwd`(可选):工作目录。local target 生效(子进程 `current_dir`);**ssh target wave-1 不支持,非空即报 `completion: cwd is not supported for ssh targets in wave 1`**(远端 cwd 语义留 wave 2 定义,显式失败优于静默在错误目录执行)。 +- `timeoutMs`:completion 层超时,clamp 到 [200, 3000],缺省(0/缺字段)1200。 +- `maxOutputBytes`:单流输出上限,stdout 与 stderr **各自**截断;缺省(0)或超过 256 KiB 时取 256 KiB。 +- `mode`:必须为 `"completion-generator"`(防止普通 RPC 复用本通道)。 + +返回 `{ exitCode: number | null, stdout, stderr, truncated, timedOut }`: + +- `timedOut=true` 表示 completion 层竞速超时(底层执行已尝试取消回收),此时 `exitCode=null`、输出为空; +- `truncated=true` 表示 stdout 或 stderr 到达 `maxOutputBytes` 上限被截断; +- ssh target 复用 `SshRuntime::exec` 通道,其返回的 `output` 是 **stdout+stderr 合并流**,显式映射到 `stdout`、`stderr` 恒为空(generator 按约定写 stdout,合并流对解析无影响)。 + +语义要点: + +- **sudo 恒 false**:本通道永不提权; +- **只读连接拒绝**(决策 D4):ssh target 在只读连接上一律报错——generator 即命令执行,不能绕过只读承诺;local target 无只读概念; +- **超时竞速**(决策 D3):超时在 completion 层用 `tokio::time::timeout` 实现,不改 `ssh/exec` 内部的 5–300 秒下限;ssh 路径超时后以 `execId`(`completion-` 前缀,与用户手写 execId 命名空间区分)走 `ssh/exec/cancel` 同路径回收在途任务,local 路径超时杀子进程并收尸; +- **远端拼装**:`command` 原样 + `args` 逐个 `exec::shell_quote` 单引号转义后拼为一行,由远端默认 shell 解释(注入面只在 args,全部转义);local 路径 argv 直 exec 不经 shell(Windows 无需引号处理); +- **local 隔离**:短生命周期子进程(stdin 接 /dev/null、`kill_on_drop`),不占用 `local/terminal/*` 的交互 PTY; +- 错误统一字符串 Err 惯例并带 **`completion:` 前缀** 分类(如 `completion: mode not allowed`、`completion: invalid command`、`completion: too many args (max 32)`)。 + +wave-1 不做 `completion/listDirectory`、`completion/environment`(wave 2+)。端到端冒烟:`scripts/smoke_completion.py`(方法未注册时 SKIP 而非 FAIL)。 From eb8d809542e04c7d334c6b1aad3561b42b8ce3af Mon Sep 17 00:00:00 2001 From: jinpy666 Date: Mon, 28 Sep 2026 14:07:44 +0800 Subject: [PATCH 06/22] =?UTF-8?q?test(completion):=20=E6=96=B0=E5=A2=9E=20?= =?UTF-8?q?smoke=5Fcompletion.py=20=E5=86=92=E7=83=9F=EF=BC=889=20?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐 smoke_fs_test 约定(Method not found → SKIP、CaseResult 记账、 前置用例依赖)。覆盖 local echo/超时竞速/输出截断、安全拒绝 (mode/空 command)、ssh 基础/参数引号往返/未知会话/超时竞速/只读 连接拒绝(D4)。本机 docker 测试容器实测 9 PASS / 0 SKIP / 0 FAIL。 --- scripts/smoke_completion.py | 352 ++++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100755 scripts/smoke_completion.py diff --git a/scripts/smoke_completion.py b/scripts/smoke_completion.py new file mode 100755 index 00000000..7a435dde --- /dev/null +++ b/scripts/smoke_completion.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""End-to-end smoke test for the completion/execute RPC (FIG wave-1 lane B). + +Reuses the connection flow from smoke_test.py: initialize -> connection/connect -> +connection/test (challenge auto-accepted) -> ssh/session/open, then exercises +completion/execute against local and ssh targets on the test container. The +method may not be registered in main.rs yet; any "Method not found" answer is +reported as SKIP so the script passes both before and after wiring (only +connection setup failures or real method errors count as FAIL). + +Usage: + python3 scripts/smoke_completion.py # default test container + python3 scripts/smoke_completion.py --host H --port P --user U --password W +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from sidecar_client import SidecarClient, SidecarError, lifecycle_params + + +def step(name: str): + print(f"\n==> {name}") + + +class SkipSignal(Exception): + """Raised by a case to skip itself for environmental reasons.""" + + +def fail(message: str, client: SidecarClient | None = None): + if client: + client.close() + print(f"\nFAIL: {message}", file=sys.stderr) + sys.exit(1) + + +def auto_accept_challenge(event: dict) -> dict | None: + """Auto-accept host-key challenges while a request is in flight.""" + if event.get("method") != "connection/challenge": + return None + params = event.get("params", {}).get("params") or event.get("params", {}) + if "challengeId" not in params: + return None + print(f" host-key challenge: {params.get('keyType')} {str(params.get('fingerprint'))[:32]}...") + return { + "method": "ssh/host-key/resolve", + "params": { + "challengeId": params["challengeId"], + "operationId": params.get("operationId"), + "accept": True, + "remember": True, + }, + } + + +def missing_method(error: Exception) -> str | None: + """Return the unregistered method name if the error means 'Method not found'.""" + text = str(error) + if "Method not found" not in text and "-32601" not in text: + return None + match = re.search(r"Method not found:\s*([\w./-]+)", text) + return match.group(1) if match else "" + + +IS_WINDOWS = sys.platform.startswith("win") + + +class Report: + """Per-case PASS/SKIP/FAIL bookkeeping with the smoke_test reporting style.""" + + def __init__(self): + self.passed: list[str] = [] + self.skipped: list[tuple[str, str]] = [] + self.failed: list[tuple[str, str]] = [] + + def run(self, title: str, method: str, case, needs: str | None = None): + """Run one case; SKIP on Method not found, FAIL on any other error. + + `needs` gates chained cases: it must name a case that PASSED before + this one runs, otherwise this case is SKIPped as unreachable. + """ + step(title) + if needs and needs not in self.passed: + print(f"SKIP: prerequisite '{needs}' did not pass") + self.skipped.append((title, f"prerequisite '{needs}' did not pass")) + return + try: + case() + except SkipSignal as reason: + print(f"SKIP: {reason}") + self.skipped.append((title, str(reason))) + except SidecarError as error: + missing = missing_method(error) + if missing is not None: + print(f"SKIP: {missing or method} not registered yet") + self.skipped.append((title, f"{missing or method} not registered yet")) + else: + print(f"FAIL: {error}") + self.failed.append((title, str(error))) + except Exception as error: # noqa: BLE001 - smoke bookkeeping + print(f"FAIL: {error}") + self.failed.append((title, str(error))) + else: + print(" PASS") + self.passed.append(title) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=2222) + parser.add_argument("--user", default="sshuser") + parser.add_argument("--password", default="DbxTest2026") + args = parser.parse_args() + + started = time.monotonic() + client = SidecarClient.start(timeout=30) + report = Report() + connection_id = "smoke-completion-connection" + workbench_id = "smoke-completion" + session_id = "" + read_only_connection_id = "smoke-completion-readonly" + try: + step("plugin/initialize") + info = client.initialize() + print(json.dumps(info, ensure_ascii=False)[:200]) + + connection = { + "id": connection_id, + "name": "smoke-completion", + "db_type": "ssh", + "host": args.host, + "port": args.port, + "username": args.user, + "password": args.password, + "external_config": {"authentication": "password"}, + } + + step("connection/connect") + result = client.request("connection/connect", lifecycle_params(connection)) + print(json.dumps(result, ensure_ascii=False)) + + step("connection/test (challenge auto-accepted, remembered)") + result = client.request("connection/test", lifecycle_params(connection), + timeout=90, on_event=auto_accept_challenge) + print(f" test ok: {json.dumps(result, ensure_ascii=False)[:100]}") + + step("ssh/session/open") + session = client.request("ssh/session/open", + {"connectionId": connection_id, "workbenchId": workbench_id, + "cols": 120, "rows": 30}, + timeout=60, on_event=auto_accept_challenge) + session_id = session.get("sessionId", workbench_id) + print(f" session {session_id} opened") + + def req(method: str, params: dict | None = None, timeout: float = 60.0) -> dict: + return client.request(method, params, timeout=timeout, + on_event=auto_accept_challenge) + + def exec_request(target: dict, command: str, args_list: list[str], + mode: str = "completion-generator", **overrides) -> dict: + payload = { + "target": target, + "command": command, + "args": args_list, + "timeoutMs": 2000, + "maxOutputBytes": 65536, + "mode": mode, + } + payload.update(overrides) + return req("completion/execute", payload) + + local_target = {"kind": "local", "sessionId": workbench_id} + ssh_target = {"kind": "ssh", "sessionId": session_id} + + # ---- local group ----------------------------------------------------- + + def case_local_echo(): + result = exec_request(local_target, "printf", ["hello"]) + if result.get("exitCode") != 0 or result.get("stdout") != "hello": + raise AssertionError(f"want stdout='hello' exit=0, got {json.dumps(result)[:200]}") + if result.get("timedOut") or result.get("truncated"): + raise AssertionError(f"unexpected flags: {json.dumps(result)[:200]}") + print(f" stdout={result.get('stdout')!r}") + + def case_local_timeout(): + if IS_WINDOWS: + raise SkipSignal("unix-only case (sleep); Windows host skips") + result = exec_request(local_target, "sleep", ["5"], timeoutMs=400) + if result.get("timedOut") is not True or result.get("exitCode") is not None: + raise AssertionError(f"want timedOut=true exitCode=null, got {json.dumps(result)[:200]}") + if result.get("stdout"): + raise AssertionError("timed-out local exec must drop output") + + def case_local_truncation(): + if IS_WINDOWS: + raise SkipSignal("unix-only case (yes); Windows host skips") + result = exec_request(local_target, "yes", ["x"], maxOutputBytes=1024) + if result.get("truncated") is not True: + raise AssertionError(f"want truncated=true, got {json.dumps(result)[:200]}") + if result.get("timedOut"): + raise AssertionError("truncation must kill the child, not wait for the timeout") + if len(result.get("stdout", "")) > 1024: + raise AssertionError(f"stdout exceeds cap: {len(result['stdout'])}") + + def case_security_rejects(): + try: + exec_request(local_target, "printf", ["x"], mode="evil") + except SidecarError as error: + if "completion:" not in str(error): + raise AssertionError(f"mode reject lacks completion: prefix: {error}") + else: + raise AssertionError("mode='evil' unexpectedly accepted") + try: + exec_request(local_target, "", []) + except SidecarError as error: + if "completion:" not in str(error): + raise AssertionError(f"empty command reject lacks prefix: {error}") + else: + raise AssertionError("empty command unexpectedly accepted") + print(" mode/empty-command rejections carry the completion: prefix") + + # ---- ssh group ------------------------------------------------------- + + def case_ssh_basic(): + result = exec_request(ssh_target, "printf", ["hi"]) + if result.get("exitCode") != 0 or result.get("stdout") != "hi": + raise AssertionError(f"want stdout='hi' exit=0, got {json.dumps(result)[:200]}") + print(f" stdout={result.get('stdout')!r}") + + def case_ssh_quotes_args(): + result = exec_request(ssh_target, "printf", ["a b'c"]) + if result.get("stdout") != "a b'c": + raise AssertionError(f"quote round-trip broken: {json.dumps(result)[:200]}") + print(f" stdout={result.get('stdout')!r}") + + def case_ssh_unknown_session(): + try: + exec_request({"kind": "ssh", "sessionId": "no-such-session-xyz"}, "printf", ["x"]) + except SidecarError as error: + if "completion:" not in str(error): + raise AssertionError(f"unknown session error lacks prefix: {error}") + else: + raise AssertionError("unknown session unexpectedly accepted") + print(" unknown session rejected with completion: prefix") + + def case_ssh_timeout(): + # linuxserver/openssh-server 是 busybox 环境;缺 sleep 时按环境 SKIP。 + probe = req("ssh/exec", {"sessionId": session_id, "command": "command -v sleep"}) + if "sleep" not in str(probe.get("output", "")): + raise SkipSignal("container has no sleep binary") + started_at = time.monotonic() + result = exec_request(ssh_target, "sleep", ["5"], timeoutMs=400) + elapsed = time.monotonic() - started_at + if result.get("timedOut") is not True: + raise AssertionError(f"want timedOut=true, got {json.dumps(result)[:200]}") + if elapsed > 5: + raise AssertionError(f"timeout race ineffective: {elapsed:.1f}s") + + def case_ssh_read_only_rejected(): + # 用独立只读连接验证决策 D4:read_only 连接上 ssh target 一律拒绝。 + read_only_connection = dict(connection) + read_only_connection.update({ + "id": read_only_connection_id, + "name": "smoke-completion-readonly", + "read_only": True, + }) + try: + client.request("connection/connect", lifecycle_params(read_only_connection)) + client.request("connection/test", lifecycle_params(read_only_connection), + timeout=90, on_event=auto_accept_challenge) + opened = req("ssh/session/open", { + "connectionId": read_only_connection_id, + "workbenchId": "smoke-completion-readonly-wb", + "cols": 120, "rows": 30, + }) + except SidecarError as error: + raise SkipSignal(f"read-only connection unavailable: {error}") + read_only_session_id = opened.get("sessionId", "smoke-completion-readonly-wb") + try: + exec_request({"kind": "ssh", "sessionId": read_only_session_id}, + "printf", ["x"]) + except SidecarError as error: + if "read-only" not in str(error) or "completion:" not in str(error): + raise AssertionError(f"unexpected read-only rejection: {error}") + else: + raise AssertionError("read-only connection unexpectedly allowed completion/execute") + print(" read-only connection rejected with completion: prefix") + + report.run("local echo", "completion/execute", case_local_echo) + report.run("local timeout race", "completion/execute", case_local_timeout) + report.run("local output truncation", "completion/execute", case_local_truncation) + report.run("security rejections (mode/command)", "completion/execute", + case_security_rejects) + report.run("ssh basic exec", "completion/execute", case_ssh_basic) + report.run("ssh arg shell-quoting", "completion/execute", case_ssh_quotes_args, + needs="ssh basic exec") + report.run("ssh unknown session", "completion/execute", case_ssh_unknown_session) + report.run("ssh timeout race", "completion/execute", case_ssh_timeout, + needs="ssh basic exec") + report.run("ssh read-only rejected", "completion/execute", case_ssh_read_only_rejected, + needs="ssh basic exec") + + # ---- cleanup --------------------------------------------------------- + + step("cleanup") + try: + client.request("ssh/session/close", {"sessionId": session_id}) + print(" session closed") + except SidecarError: + pass + read_only_connection = { + "id": read_only_connection_id, + "name": "smoke-completion-readonly", + "db_type": "ssh", + "host": args.host, + "port": args.port, + "username": args.user, + "password": args.password, + "external_config": {"authentication": "password"}, + "read_only": True, + } + for connection_payload in (read_only_connection, connection): + try: + client.request("connection/disconnect", lifecycle_params(connection_payload)) + except SidecarError: + pass + client.close() + step(f"summary ({time.monotonic() - started:.1f}s)") + print(f"PASS {len(report.passed)} / SKIP {len(report.skipped)} / FAIL {len(report.failed)}") + for name, reason in report.skipped: + print(f" SKIP {name}: {reason}") + for name, error in report.failed: + print(f" FAIL {name}: {error}", file=sys.stderr) + if report.failed: + sys.exit(1) + print("PASS: completion/execute smoke OK") + except SidecarError as error: + fail(str(error), client) + except KeyboardInterrupt: + client.close() + + +if __name__ == "__main__": + main() From f4f78d8bbd3948691294a6d0f7c4fdffdb616f2b Mon Sep 17 00:00:00 2001 From: jinpy666 Date: Mon, 28 Sep 2026 14:25:31 +0800 Subject: [PATCH 07/22] =?UTF-8?q?fix(ui):=20=E5=BC=B9=E5=B1=82=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E5=86=BB=E7=BB=93=E4=BF=9D=E9=99=A9=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E6=B8=B2=E6=9F=93=E5=99=A8=E5=81=9C=E6=91=86?= =?UTF-8?q?=E6=97=B6=E5=BC=B9=E7=AA=97=E4=B8=8D=E5=86=8D=E5=AE=9A=E6=A0=BC?= =?UTF-8?q?=E9=80=8F=E6=98=8E=E9=A6=96=E5=B8=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux 宿主(GPU 合成停摆/动画被节流的 webview)里,reka 弹层的入场动画 (tw-animate-css `enter`,首帧 opacity 0)会永远冻结在第一帧:弹窗实际 已打开但全透明、无任何报错——用户侧即「点了按钮,弹窗出不来,也没有明 显的提示」(mockDbxHost ?noanim 注释记载过的同款"鬼影弹层"现象,浏览器 走查已在动画被节流的面板里实测复现)。 新增 lib/popupReveal.ts 兜底:body 级 MutationObserver 监听弹层挂载 (popover/dialog/select/context-menu 四类 data-slot;组件 ref 只能拿到 portal 锚点注释节点,够不着 teleport 后的弹层本体),挂载 300ms 后弹层 仍不足全不透明则把卡住的动画 finish 到终态——fill-mode none,动画结束 回落自然可见样式。正常宿主动画 duration-100 远早于观察点结束,此路径 零视觉差异;观察器回调走微任务,不依赖渲染帧,冻结宿主里照样触发。 验证:popupReveal 单测 9 条;前端全套 1293/1293 通过;vue-tsc 干净; node build.mjs 生产构建通过;动画冻结的浏览器面板端到端复现修复效果 (Docker 鲸鱼面板与强杀确认弹窗均正常显形)。 --- frontend/src/lib/popupReveal.spec.ts | 137 +++++++++++++++++++++++++++ frontend/src/lib/popupReveal.ts | 69 ++++++++++++++ frontend/src/main.ts | 4 + 3 files changed, 210 insertions(+) create mode 100644 frontend/src/lib/popupReveal.spec.ts create mode 100644 frontend/src/lib/popupReveal.ts diff --git a/frontend/src/lib/popupReveal.spec.ts b/frontend/src/lib/popupReveal.spec.ts new file mode 100644 index 00000000..50dbfd7d --- /dev/null +++ b/frontend/src/lib/popupReveal.spec.ts @@ -0,0 +1,137 @@ +// @vitest-environment happy-dom +// 弹层动画冻结保险("鬼影弹层"兜底):宿主渲染器冻结 CSS 动画时,reka 入场 +// 动画定格在首帧(opacity 0),弹层"已打开但永远透明"。kick 逻辑只在 +// 300ms 后弹层仍不足全不透明时才把冻结动画 finish 到终态(= 自然可见样式)。 +import { describe, expect, it, vi } from "vitest"; +import { popupRevealKick, schedulePopupReveal, watchPopupReveal } from "./popupReveal"; + +function mountedEl(): HTMLElement { + const el = document.createElement("div"); + document.body.appendChild(el); + return el; +} + +/** 测试用假动画:只需 playState/finish 两个字段供 kick 分支与断言使用。 */ +interface FakeAnimation { + playState: string; + finish: () => void; +} + +/** 覆盖(或置空,模拟无 WAAPI 的老引擎)元素上的 getAnimations。 */ +function setAnimations(el: HTMLElement, anims: FakeAnimation[] | undefined): void { + (el as unknown as Record).getAnimations = anims ? () => anims : undefined; +} + +function stubOpacity(el: HTMLElement, opacity: string): void { + const win = el.ownerDocument.defaultView!; + vi.spyOn(win, "getComputedStyle").mockImplementation( + () => ({ opacity }) as unknown as CSSStyleDeclaration, + ); +} + +describe("popupRevealKick", () => { + it("does nothing when the element is already fully opaque (healthy host)", () => { + const el = mountedEl(); + stubOpacity(el, "1"); + expect(popupRevealKick(el)).toBe(false); + }); + + it("ignores elements detached from the document", () => { + const el = document.createElement("div"); + stubOpacity(el, "0"); + expect(popupRevealKick(el)).toBe(false); + }); + + it("falls back to inline animation:none when getAnimations is unavailable", () => { + const el = mountedEl(); + setAnimations(el, undefined); + stubOpacity(el, "0"); + expect(popupRevealKick(el)).toBe(true); + expect(el.style.animation).toBe("none"); + }); + + it("finishes running and paused frozen animations via WAAPI", () => { + const finish = vi.fn(); + const finishPaused = vi.fn(); + const el = mountedEl(); + stubOpacity(el, "0"); + setAnimations(el, [ + { playState: "running", finish }, + { playState: "paused", finish: finishPaused }, + { playState: "finished", finish: vi.fn() }, + ]); + expect(popupRevealKick(el)).toBe(true); + expect(finish).toHaveBeenCalledTimes(1); + expect(finishPaused).toHaveBeenCalledTimes(1); + }); + + it("reports a no-op when a WAAPI host has nothing to finish", () => { + const el = mountedEl(); + stubOpacity(el, "0.4"); + setAnimations(el, []); + expect(popupRevealKick(el)).toBe(false); + }); +}); + +describe("schedulePopupReveal", () => { + it("kicks the element once the delay elapses and supports cancel", () => { + vi.useFakeTimers(); + const el = mountedEl(); + setAnimations(el, undefined); + stubOpacity(el, "0"); + const cancel = schedulePopupReveal(el, 300); + vi.advanceTimersByTime(299); + expect(el.style.animation).toBe(""); + vi.advanceTimersByTime(1); + expect(el.style.animation).toBe("none"); + cancel(); + vi.useRealTimers(); + }); + + it("cancelled timers never kick", () => { + vi.useFakeTimers(); + const el = mountedEl(); + setAnimations(el, undefined); + stubOpacity(el, "0"); + const cancel = schedulePopupReveal(el, 300); + cancel(); + vi.advanceTimersByTime(1000); + expect(el.style.animation).toBe(""); + vi.useRealTimers(); + }); +}); + +describe("watchPopupReveal", () => { + it("schedules a reveal when a popup layer mounts anywhere under the root", async () => { + vi.useFakeTimers(); + watchPopupReveal(document.body); + const wrapper = document.createElement("div"); + const layer = document.createElement("div"); + layer.setAttribute("data-slot", "dialog-content"); + wrapper.appendChild(layer); + document.body.appendChild(wrapper); + setAnimations(layer, undefined); + stubOpacity(layer, "0"); + // MutationObserver 回调走微任务,先 flush 再推进定时器。 + await vi.advanceTimersByTimeAsync(0); + vi.advanceTimersByTime(299); + expect(layer.style.animation).toBe(""); + vi.advanceTimersByTime(1); + expect(layer.style.animation).toBe("none"); + vi.useRealTimers(); + }); + + it("ignores mounted nodes without a popup layer", async () => { + vi.useFakeTimers(); + watchPopupReveal(document.body); + const plain = document.createElement("div"); + plain.textContent = "no layer here"; + document.body.appendChild(plain); + setAnimations(plain, undefined); + stubOpacity(plain, "0"); + await vi.advanceTimersByTimeAsync(0); + vi.advanceTimersByTime(1000); + expect(plain.style.animation).toBe(""); + vi.useRealTimers(); + }); +}); diff --git a/frontend/src/lib/popupReveal.ts b/frontend/src/lib/popupReveal.ts new file mode 100644 index 00000000..5540fa55 --- /dev/null +++ b/frontend/src/lib/popupReveal.ts @@ -0,0 +1,69 @@ +/** + * 弹层动画冻结保险("鬼影弹层"兜底)。 + * + * reka 弹层(Popover/Dialog 等)的入场动画由 tw-animate-css 的 `enter` + * keyframes 驱动,首帧 opacity=0。宿主渲染器一旦冻结 CSS 动画(GPU 合成 + * 停摆、动画被节流的 Linux webview 等),弹层会永远停在透明帧——用户点击 + * 按钮后"弹窗出不来",控制台也没有任何报错(mockDbxHost 的 ?noanim 注释 + * 记载过同款现象)。 + * + * 应对:watchPopupReveal 以 body 级 MutationObserver 监听弹层挂载(reka + * portal 到 body;组件 ref 只能拿到 portal 锚点注释节点,够不着真正的弹层 + * 元素),安排一次延迟观察——届时弹层仍不足全不透明,就把卡住的动画 + * finish 到终态:fill-mode 为 none,动画结束后回落自然样式(opacity 1), + * 弹窗立即显形。正常宿主动画 duration-100 远早于观察点结束,这里不会出手, + * 零视觉差异;只处理入场,退场冻结只影响卸载时机、不影响可见性。 + */ +const REVEAL_DELAY_MS = 300; + +/** 弹层本体(wrapper 统一携带的 data-slot),定位/装饰性外层不在其列。 */ +const POPUP_LAYER_SELECTOR = + '[data-slot="popover-content"], [data-slot="dialog-content"], [data-slot="select-content"], [data-slot="context-menu-content"]'; + +/** 立即检查一次:不足全不透明则把 running/paused 的冻结动画推到终态。 + * 返回是否实际出手(供单测断言)。 */ +export function popupRevealKick(el: Element): boolean { + if (!el.isConnected) return false; + const win = el.ownerDocument?.defaultView; + if (!win) return false; + if (Number.parseFloat(win.getComputedStyle(el).opacity) >= 1) return false; + if (typeof (el as HTMLElement).getAnimations === "function") { + let kicked = false; + for (const anim of (el as HTMLElement).getAnimations()) { + if (anim.playState === "running" || anim.playState === "paused") { + anim.finish(); + kicked = true; + } + } + return kicked; + } + // 无 WAAPI 的老引擎兜底:直接摘除动画,落到自然可见样式。 + (el as HTMLElement).style.animation = "none"; + return true; +} + +/** 弹层挂载后安排一次延迟观察;返回取消函数(单测用,生产路径可忽略)。 */ +export function schedulePopupReveal(el: Element, delayMs: number = REVEAL_DELAY_MS): () => void { + const timer = window.setTimeout(() => popupRevealKick(el), delayMs); + return () => window.clearTimeout(timer); +} + +let revealObserver: MutationObserver | undefined; + +/** 全局监听弹层挂载并安排各自的 reveal。MutationObserver 回调走微任务, + * 不依赖渲染帧——动画冻结的宿主里照样触发(定时器亦不受渲染停摆影响)。 */ +export function watchPopupReveal(root: ParentNode = document.body): void { + if (revealObserver || typeof MutationObserver === "undefined") return; + revealObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (!(node instanceof Element)) continue; + const layer = node.matches(POPUP_LAYER_SELECTOR) + ? node + : node.querySelector(POPUP_LAYER_SELECTOR); + if (layer) schedulePopupReveal(layer); + } + } + }); + revealObserver.observe(root, { childList: true, subtree: true }); +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index aaeb4e56..c1f9a8ed 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -5,6 +5,7 @@ import "./style.css"; import "./styles/tailwind.css"; import { installHostThemeBridge } from "../../shared/frontend/themeSync"; import { pluginStore } from "./lib/pluginStore"; +import { watchPopupReveal } from "./lib/popupReveal"; // 宿主令牌 → 插件变量桥:首绘即命中宿主主题,主题变化经 SDK 令牌更新自动跟随。 // 字体回退值覆盖为插件规范链(UI 字体补 CJK 回退,与 style.css :root 一致; @@ -18,6 +19,9 @@ installHostThemeBridge({ // 保证 App setup 内的同步首读(面板形态 / 字体 / WebGL 等偏好)命中持久化值。 const boot = async () => { await pluginStore.ready; + // 弹层动画冻结保险先于挂载装好:宿主渲染器停摆(动画定格透明首帧)时, + // 弹层挂载 300ms 后被强制落到可见态(lib/popupReveal.ts)。 + watchPopupReveal(); createApp(App).mount("#app"); }; boot(); \ No newline at end of file From b7d04770005a90e74352a902848354b1c6e0dfd8 Mon Sep 17 00:00:00 2001 From: jinpy666 Date: Mon, 28 Sep 2026 14:45:35 +0800 Subject: [PATCH 08/22] =?UTF-8?q?feat(completion):=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E5=86=85=E6=A0=B8=E6=A8=A1=E5=9D=97=E5=8C=96=E2=80=94=E2=80=94?= =?UTF-8?q?edit/ranking/keyboard/controller=20=E4=B8=8E=20Fake=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core/edit:applyEditToText(越界向行界收敛,不抛错)+ trailingTokenEdit - core/ranking:rankItems 唯一排序出口(score 降序/label 字典序/截 20 条) - keyboard:契约 §2.2 规则表纯函数(Enter 恒放行、hint/loading Tab 透传), 全组合表驱动单测固化 - CompletionController:resolver=冻结接缝 FigCompletionSource(构造注入), 三重 guard(revision+requestId+sessionId)/90ms 防抖合并/异常降级 pass-through/enabled 直通;防御 thenable 分支为 Worker 化留位 - testing/fakeFigSource:确定性 Fake(git ch 静态候选、git co 别名展开、 深参数位 null)驱动 controller 全路径单测 + dev 手动清单 --- .../completion/CompletionController.spec.ts | 379 ++++++++++++++++++ .../lib/completion/CompletionController.ts | 148 +++++++ frontend/src/lib/completion/core/edit.spec.ts | 97 +++++ frontend/src/lib/completion/core/edit.ts | 42 ++ .../src/lib/completion/core/ranking.spec.ts | 43 ++ frontend/src/lib/completion/core/ranking.ts | 18 + frontend/src/lib/completion/keyboard.spec.ts | 86 ++++ frontend/src/lib/completion/keyboard.ts | 50 +++ .../lib/completion/testing/fakeFigSource.ts | 135 +++++++ 9 files changed, 998 insertions(+) create mode 100644 frontend/src/lib/completion/CompletionController.spec.ts create mode 100644 frontend/src/lib/completion/CompletionController.ts create mode 100644 frontend/src/lib/completion/core/edit.spec.ts create mode 100644 frontend/src/lib/completion/core/edit.ts create mode 100644 frontend/src/lib/completion/core/ranking.spec.ts create mode 100644 frontend/src/lib/completion/core/ranking.ts create mode 100644 frontend/src/lib/completion/keyboard.spec.ts create mode 100644 frontend/src/lib/completion/keyboard.ts create mode 100644 frontend/src/lib/completion/testing/fakeFigSource.ts diff --git a/frontend/src/lib/completion/CompletionController.spec.ts b/frontend/src/lib/completion/CompletionController.spec.ts new file mode 100644 index 00000000..49082b75 --- /dev/null +++ b/frontend/src/lib/completion/CompletionController.spec.ts @@ -0,0 +1,379 @@ +// CompletionController 单测(FIG wave-1 Lane A'):resolver = 冻结接缝 +// FigCompletionSource(真实 Fake 驱动 + 桩源驱动异常/stale 路径)。覆盖: +// ready 同步交付 / pass-through(null) / 异常吞掉降级 / stale 丢弃(三重 +// guard:revision+requestId+sessionId)/ enabled=false / debounce 合并 / +// accept→onAcceptEdit 边界。FakeFigCompletionSource 自身的小语料行为 +// (别名/深层 null/flag)也在本文件一并锁定。 +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CompletionController } from "./CompletionController"; +import { FakeFigCompletionSource, createPassThroughFigSource } from "./testing/fakeFigSource"; +import type { CompletionEdit, CompletionItem, CompletionResponse } from "./core/types"; +import type { FigCompletionSource, FigSourceRequest } from "./fig/source"; + +/** 挂起式异步 source(防御 thenable 分支 + stale 路径驱动)。冻结接缝类型为 + * 同步,此处显式断言模拟 Worker 化未来形态,生产实现不受影响。 */ +function deferredSource(): { source: FigCompletionSource; pending: Array<{ resolve: (response: CompletionResponse | null) => void; reject: (cause?: unknown) => void }> } { + const pending: Array<{ resolve: (response: CompletionResponse | null) => void; reject: (cause?: unknown) => void }> = []; + const source: FigCompletionSource = { + id: "deferred", + resolve: () => + new Promise((resolve, reject) => pending.push({ resolve, reject })) as unknown as CompletionResponse | null, + }; + return { source, pending }; +} + +function item(label: string, overrides: Partial = {}): CompletionItem { + return { + id: `test:${label}`, + label, + description: `${label} description`, + kind: "subcommand", + score: 100, + source: "test", + edit: { text: `${label} `, replaceStart: 4, replaceEnd: 6 }, + ...overrides, + }; +} + +function readyResponse(request: { requestId: number; revision: number }, items: CompletionItem[]): CompletionResponse { + return { + requestId: request.requestId, + revision: request.revision, + state: "ready", + context: { command: "git", commandPath: ["git"], tokenStart: 4, tokenEnd: 6 }, + items, + }; +} + +interface Harness { + controller: CompletionController; + responses: CompletionResponse[]; + edits: CompletionEdit[]; + setLine: (line: string) => void; + setSessionId: (sessionId: string) => void; + setEnabled: (enabled: boolean) => void; + seenRequests: FigSourceRequest[]; +} + +function harness(source: FigCompletionSource, debounceMs = 90): Harness { + let line = "git ch"; + let sessionId = "session-a"; + let enabled = true; + const responses: CompletionResponse[] = []; + const edits: CompletionEdit[] = []; + const seenRequests: FigSourceRequest[] = []; + const controller = new CompletionController({ + source: { + id: source.id, + resolve: (request) => { + seenRequests.push(request); + return source.resolve(request); + }, + }, + sessionId: () => sessionId, + readLine: () => line, + enabled: () => enabled, + debounceMs, + onResponse: (response) => responses.push(response), + onAcceptEdit: (edit) => edits.push(edit), + }); + return { + controller, + responses, + edits, + seenRequests, + setLine: (next) => (line = next), + setSessionId: (next) => (sessionId = next), + setEnabled: (next) => (enabled = next), + }; +} + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +describe("CompletionController · FakeFigCompletionSource 驱动(同步交付)", () => { + it("delivers a ready response synchronously inside request()", () => { + const { controller, responses } = harness(new FakeFigCompletionSource()); + controller.request("typing"); + expect(responses).toHaveLength(1); + expect(responses[0].state).toBe("ready"); + expect(responses[0].items.map((entry) => entry.label)).toContain("checkout"); + // request 元数据原样回带(三重 guard 的锚点)。 + expect(responses[0].requestId).toBe(1); + expect(responses[0].revision).toBe(0); + }); + + it("resolves against the line snapshot taken at dispatch time", () => { + const { controller, responses, setLine } = harness(new FakeFigCompletionSource()); + setLine("git co"); + controller.request("typing"); + // "co" 前缀同时命中 checkout(别名展开)与 commit/config;checkout 以别名分入列。 + const checkout = responses[0].items.find((entry) => entry.label === "checkout"); + expect(checkout?.score).toBe(90); + expect(responses[0].items.map((entry) => entry.label)).toEqual(expect.arrayContaining(["commit", "config"])); + }); + + it("pass-through: unknown command resolves to null and degrades to a pass-through response", () => { + const { controller, responses, setLine } = harness(new FakeFigCompletionSource()); + setLine("unknowncmd x"); + controller.request("typing"); + expect(responses).toHaveLength(1); + expect(responses[0].state).toBe("pass-through"); + expect(responses[0].items).toEqual([]); + }); + + it("pass-through: deeper argument positions resolve to null (generator 领域)", () => { + const { controller, responses, setLine } = harness(new FakeFigCompletionSource()); + setLine("git checkout "); + controller.request("typing"); + expect(responses[0].state).toBe("pass-through"); + }); + + it("flag prefix candidates carry option kind and per-item edits", () => { + const { controller, responses, setLine } = harness(new FakeFigCompletionSource()); + setLine("git -"); + controller.request("typing"); + expect(responses[0].items.every((entry) => entry.kind === "option")).toBe(true); + expect(responses[0].items[0].edit.replaceStart).toBe(4); + }); +}); + +describe("CompletionController · 异常降级(PTY 红线)", () => { + it("degrades a throwing sync source to a pass-through response without rethrowing", () => { + const boom: FigCompletionSource = { + id: "boom", + resolve: () => { + throw new Error("parser exploded"); + }, + }; + const { controller, responses } = harness(boom); + expect(() => controller.request("typing")).not.toThrow(); + expect(responses).toHaveLength(1); + expect(responses[0].state).toBe("pass-through"); + expect(responses[0].items).toEqual([]); + }); + + it("degrades a rejecting async source to a pass-through response", async () => { + const deferred = deferredSource(); + const { controller, responses } = harness(deferred.source); + controller.request("typing"); + deferred.pending[0].reject(new Error("rpc failed")); + await vi.advanceTimersByTimeAsync(0); + expect(responses).toHaveLength(1); + expect(responses[0].state).toBe("pass-through"); + }); + + it("swallows a throwing onAcceptEdit instead of bubbling into the key handler", () => { + const edits: CompletionEdit[] = []; + const controller = new CompletionController({ + source: new FakeFigCompletionSource(), + sessionId: () => "s", + readLine: () => "git ch", + enabled: () => true, + onResponse: () => undefined, + onAcceptEdit: (edit) => { + edits.push(edit); + throw new Error("terminal write failed"); + }, + }); + const candidate = item("checkout"); + expect(() => controller.accept(candidate)).not.toThrow(); + expect(edits).toHaveLength(1); + }); +}); + +describe("CompletionController · 三重 guard(stale 丢弃)", () => { + it("drops a response whose revision advanced while resolving (lineChanged re-entrancy)", () => { + const { controller, responses } = harness({ + id: "reentrant", + resolve: (request) => { + // source 同步解析期间行又变了:响应带着旧 revision 返回,必须被丢弃。 + controller.lineChanged(); + return readyResponse(request, [item("checkout")]); + }, + }); + controller.request("typing"); + expect(responses).toHaveLength(0); + }); + + it("drops a response whose requestId was superseded by a newer dispatch", async () => { + const deferred = deferredSource(); + const { controller, responses } = harness(deferred.source); + controller.request("typing"); + controller.request("manual"); + expect(deferred.pending).toHaveLength(2); + deferred.pending[0].resolve(readyResponse({ requestId: 1, revision: 0 }, [item("checkout")])); + await vi.advanceTimersByTimeAsync(0); + expect(responses).toHaveLength(0); + deferred.pending[1].resolve(readyResponse({ requestId: 2, revision: 0 }, [item("status")])); + await vi.advanceTimersByTimeAsync(0); + expect(responses).toHaveLength(1); + expect(responses[0].items[0].label).toBe("status"); + }); + + it("drops a response delivered after a session switch (sessionId guard)", async () => { + const deferred = deferredSource(); + const { controller, responses, setSessionId } = harness(deferred.source); + controller.request("typing"); + setSessionId("session-b"); + deferred.pending[0].resolve(readyResponse({ requestId: 1, revision: 0 }, [item("checkout")])); + await vi.advanceTimersByTimeAsync(0); + expect(responses).toHaveLength(0); + }); + + it("resetSession invalidates in-flight results (requestId bump)", async () => { + const deferred = deferredSource(); + const { controller, responses } = harness(deferred.source); + controller.request("typing"); + controller.resetSession(); + deferred.pending[0].resolve(readyResponse({ requestId: 1, revision: 0 }, [item("checkout")])); + await vi.advanceTimersByTimeAsync(0); + expect(responses).toHaveLength(0); + }); + + it("still delivers when all three anchors match", async () => { + const deferred = deferredSource(); + const { controller, responses } = harness(deferred.source); + controller.request("typing"); + deferred.pending[0].resolve(readyResponse({ requestId: 1, revision: 0 }, [item("checkout")])); + await vi.advanceTimersByTimeAsync(0); + expect(responses).toHaveLength(1); + expect(responses[0].state).toBe("ready"); + }); +}); + +describe("CompletionController · debounce 合并", () => { + it("coalesces multiple lineChanged calls within the window into one request", () => { + const { controller, responses, seenRequests, setLine } = harness(new FakeFigCompletionSource()); + controller.lineChanged(); + setLine("git c"); + controller.lineChanged(); + setLine("git ch"); + controller.lineChanged(); + expect(responses).toHaveLength(0); + vi.advanceTimersByTime(89); + expect(responses).toHaveLength(0); + vi.advanceTimersByTime(1); + expect(responses).toHaveLength(1); + // 只发一次,且基于最后一次行缓冲快照。 + expect(seenRequests).toHaveLength(1); + expect(seenRequests[0].line).toBe("git ch"); + expect(seenRequests[0].trigger).toBe("typing"); + }); + + it("re-arms the window on a later lineChanged", () => { + const { controller, responses } = harness(new FakeFigCompletionSource()); + controller.lineChanged(); + vi.advanceTimersByTime(60); + controller.lineChanged(); + vi.advanceTimersByTime(60); + expect(responses).toHaveLength(0); + vi.advanceTimersByTime(30); + expect(responses).toHaveLength(1); + }); + + it("request() flushes immediately and cancels the pending debounce", () => { + const { controller, responses } = harness(new FakeFigCompletionSource()); + controller.lineChanged(); + controller.request("manual"); + expect(responses).toHaveLength(1); + vi.advanceTimersByTime(500); + expect(responses).toHaveLength(1); + }); + + it("dismiss() cancels the scheduled debounce and invalidates in-flight results", async () => { + const deferred = deferredSource(); + const { controller, responses } = harness(deferred.source); + controller.request("typing"); + controller.dismiss(); + deferred.pending[0].resolve(readyResponse({ requestId: 1, revision: 0 }, [item("checkout")])); + await vi.advanceTimersByTimeAsync(0); + // dismiss 先 revision++:在途结果 revision 不再匹配 → 丢弃。 + expect(responses).toHaveLength(0); + controller.lineChanged(); + vi.advanceTimersByTime(500); + expect(responses).toHaveLength(0); + }); +}); + +describe("CompletionController · enabled 开关", () => { + it("does not schedule or resolve when disabled (lineChanged path)", () => { + const { controller, responses, setEnabled } = harness(new FakeFigCompletionSource()); + setEnabled(false); + controller.lineChanged(); + vi.advanceTimersByTime(1000); + expect(responses).toHaveLength(0); + }); + + it("does not resolve on a direct request when disabled", () => { + const { controller, responses, setEnabled } = harness(new FakeFigCompletionSource()); + setEnabled(false); + controller.request("manual"); + expect(responses).toHaveLength(0); + }); + + it("still bumps revision while disabled so late results stay stale", async () => { + const deferred = deferredSource(); + const { controller, responses, setEnabled } = harness(deferred.source); + controller.request("typing"); + setEnabled(false); + controller.lineChanged(); + setEnabled(true); + deferred.pending[0].resolve(readyResponse({ requestId: 1, revision: 0 }, [item("checkout")])); + await vi.advanceTimersByTimeAsync(0); + // 关闭期间的 lineChanged 已使 revision 前进:旧结果不得复活。 + expect(responses).toHaveLength(0); + }); +}); + +describe("CompletionController · accept", () => { + it("forwards the source-produced edit to onAcceptEdit verbatim", () => { + const { controller, edits } = harness(new FakeFigCompletionSource()); + const candidate = item("checkout"); + controller.accept(candidate); + expect(edits).toHaveLength(1); + expect(edits[0]).toEqual({ text: "checkout ", replaceStart: 4, replaceEnd: 6 }); + }); +}); + +describe("createPassThroughFigSource", () => { + it("always resolves to null (批次 1 生产占位,零浮层)", () => { + const source = createPassThroughFigSource(); + expect(source.id).toBe("pass-through"); + expect(source.resolve({ line: "git ch", requestId: 1, revision: 0, sessionId: "s", trigger: "typing" })).toBeNull(); + }); +}); + +describe("FakeFigCompletionSource · 小语料行为(手动清单锚点)", () => { + const source = new FakeFigCompletionSource(); + const requestOf = (line: string): FigSourceRequest => ({ line, requestId: 1, revision: 0, sessionId: "s", trigger: "typing" }); + + it("git ch → 静态子命令候选(checkout/cherry/cherry-pick…)", () => { + const response = source.resolve(requestOf("git ch")); + expect(response?.state).toBe("ready"); + const labels = response?.items.map((entry) => entry.label) ?? []; + expect(labels).toContain("checkout"); + expect(labels).toContain("cherry"); + expect(labels).toContain("cherry-pick"); + }); + + it("git co → 别名命中展开为 checkout(与普通前缀命中并存,别名分更高可信)", () => { + const response = source.resolve(requestOf("git co")); + const checkout = response?.items.find((entry) => entry.label === "checkout"); + expect(checkout?.kind).toBe("subcommand"); + expect(checkout?.score).toBe(90); + expect(response?.items.every((entry) => entry.edit.replaceEnd === response?.items[0]?.edit.replaceEnd)).toBe(true); + }); + + it("无命中命令 → null(Tab 透传)", () => { + expect(source.resolve(requestOf("notacommand su"))).toBeNull(); + }); + + it("空行 → null", () => { + expect(source.resolve(requestOf(""))).toBeNull(); + }); + + it("深参数位置 → null(generator 动态位示范)", () => { + expect(source.resolve(requestOf("git checkout main"))).toBeNull(); + }); +}); diff --git a/frontend/src/lib/completion/CompletionController.ts b/frontend/src/lib/completion/CompletionController.ts new file mode 100644 index 00000000..cd08c9b6 --- /dev/null +++ b/frontend/src/lib/completion/CompletionController.ts @@ -0,0 +1,148 @@ +// CompletionController(FIG wave-1 Lane A'):补全引擎的调度中枢——把 +// 「行缓冲变更 → 防抖请求 → resolver → 响应 guard → 菜单」从 App.vue 收进 +// 可测试的模块层。resolver 唯一来源 = 冻结接缝 `fig/source.ts` 的 +// FigCompletionSource(构造注入;Lane C' 提供真实实现,单测/开发用 +// testing/fakeFigSource 的 Fake)。纪律(契约 §2.1、方案 §5.1/§30/§43): +// +// 1. revision 纪律:一切结果交付前校验 requestId、revision、sessionId 三者 +// 与当前态一致,任一不匹配即静默丢弃("菜单显示 ≠ 键盘所有权"的数据面 +// 对应物:过期候选绝不进 UI)。 +// 2. resolve 全程 try/catch:任何异常降级为 state:"pass-through" 空响应, +// 绝不抛到调用方(PTY 红线:补全任何一层失败不得影响输入链路)。 +// 3. debounce 期间的多次 lineChanged 只发一次请求(旧 timer 作废)。 +// 4. enabled()===false 时 lineChanged 不调度、request 不解析(revision 仍 +// 前进,保证重新打开后的在途结果不会串期)。 +// +// 冻结 source 接口是同步的(resolve(): CompletionResponse | null,同步 +// 交付保持键入路径零时序漂移);对 thenable 的防御性等待仅为单测驱动 +// stale 路径与将来 Worker 化留位,生产实现不会走进该分支。 + +import type { CompletionEdit, CompletionItem, CompletionResponse, CompletionTrigger } from "./core/types"; +import type { FigCompletionSource, FigSourceRequest } from "./fig/source"; + +export interface CompletionControllerOptions { + /** 结构化补全唯一引擎(冻结接缝 fig/source.ts;构造注入)。 */ + source: FigCompletionSource; + /** 当前终端会话 id(无会话时空串);结果交付时校验未换会话。 */ + sessionId: () => string; + /** 返回行缓冲(pendingTerminalInput)当前值。 */ + readLine: () => string; + /** 引擎开关(设置三态合成后)+ 输入门判定;false 时不调度、不解析。 */ + enabled: () => boolean; + /** 行缓冲变更后的防抖窗口,默认 90ms。 */ + debounceMs?: number; + onResponse: (response: CompletionResponse) => void; + /** App.vue 执行终端写入(applyEditToText + 整行擦重打,机制不变)。 */ + onAcceptEdit: (edit: CompletionEdit) => void; +} + +export class CompletionController { + private revision = 0; + private requestId = 0; + private timer: ReturnType | undefined; + private readonly debounceMs: number; + private readonly source: FigCompletionSource; + + constructor(private readonly options: CompletionControllerOptions) { + this.debounceMs = options.debounceMs ?? 90; + this.source = options.source; + } + + /** + * App.vue 在行缓冲每个变更点调用(trackPendingInput / 整行替换 / ghost + * 接受 / Enter·Ctrl+C 清行):revision++ 作废在途结果,并防抖调度一次 + * request("typing")。窗口内多次调用合并为一次(旧 timer 作废)。 + */ + lineChanged(): void { + this.revision += 1; + if (!this.options.enabled()) return; + this.cancelTimer(); + this.timer = setTimeout(() => { + this.timer = undefined; + this.dispatch("typing"); + }, this.debounceMs); + } + + /** + * 立即发起一次解析(typing/tab/manual):取消挂起的防抖调度。同步 + * source 的响应在本调用内同步交付(onResponse 同步回调),键入路径的 + * 浮层刷新时机与基线一致。 + */ + request(trigger: CompletionTrigger): void { + this.cancelTimer(); + if (!this.options.enabled()) return; + this.dispatch(trigger); + } + + /** 接受候选:把 source 产生的 edit 交给 App 执行终端写入。 */ + accept(item: CompletionItem): void { + try { + this.options.onAcceptEdit(item.edit); + } catch { + // PTY 红线:接受路径的任何异常不得冒泡进按键处理。 + } + } + + /** 菜单关闭/浮层收起:取消挂起调度并把在途结果作废。 */ + dismiss(): void { + this.cancelTimer(); + this.revision += 1; + } + + /** 会话切换:重置 revision/requestId,丢弃在途结果(sessionId guard)。 */ + resetSession(): void { + this.cancelTimer(); + this.revision += 1; + this.requestId += 1; + } + + private cancelTimer(): void { + if (this.timer === undefined) return; + clearTimeout(this.timer); + this.timer = undefined; + } + + private dispatch(trigger: CompletionTrigger): void { + const requestId = (this.requestId += 1); + const revision = this.revision; + const sessionId = this.options.sessionId(); + const request: FigSourceRequest = { + line: this.options.readLine(), + requestId, + revision, + sessionId, + trigger, + }; + let outcome: CompletionResponse | null; + try { + outcome = this.source.resolve(request); + } catch { + // source 内部约定吞异常,这里再兜一层:任何抛错都降级 pass-through。 + this.deliver(requestId, revision, sessionId, this.passThrough(requestId, revision)); + return; + } + if (outcome !== null && typeof (outcome as { then?: unknown }).then === "function") { + // 防御分支:冻结接口为同步,此处仅为测试桩 / Worker 化留位。 + void Promise.resolve(outcome).then( + (response) => this.deliver(requestId, revision, sessionId, response ?? this.passThrough(requestId, revision)), + () => this.deliver(requestId, revision, sessionId, this.passThrough(requestId, revision)), + ); + return; + } + this.deliver(requestId, revision, sessionId, outcome ?? this.passThrough(requestId, revision)); + } + + private deliver(requestId: number, revision: number, sessionId: string, response: CompletionResponse): void { + // 三重 guard:requestId / revision / sessionId 任一不匹配当前态 → 静默丢弃。 + if (requestId !== this.requestId || revision !== this.revision || sessionId !== this.options.sessionId()) return; + try { + this.options.onResponse(response); + } catch { + // onResponse 属 UI 面:异常不冒泡回按键/输入路径。 + } + } + + private passThrough(requestId: number, revision: number): CompletionResponse { + return { requestId, revision, state: "pass-through", items: [] }; + } +} diff --git a/frontend/src/lib/completion/core/edit.spec.ts b/frontend/src/lib/completion/core/edit.spec.ts new file mode 100644 index 00000000..f8ddb89c --- /dev/null +++ b/frontend/src/lib/completion/core/edit.spec.ts @@ -0,0 +1,97 @@ +// applyEditToText / trailingTokenEdit 纯函数单测(FIG wave-1 Lane A)。 +// 边界对齐 legacy 接受路径的 HEAD 语义:尾空格/空行 = 行尾纯插入点、 +// 引号 token 表面、--flag=val 整体替换、行漂移时的兜底范围。 +import { describe, expect, it } from "vitest"; +import { applyEditToText, trailingTokenEdit } from "./edit"; + +describe("trailingTokenEdit", () => { + it("replaces the trailing token surface and appends a space when asked", () => { + expect(trailingTokenEdit("git ch", "checkout", true)).toEqual({ + text: "checkout ", + replaceStart: 4, + replaceEnd: 6, + }); + expect(trailingTokenEdit("git ch", "checkout", false)).toEqual({ + text: "checkout", + replaceStart: 4, + replaceEnd: 6, + }); + }); + + it("degenerates to a pure insertion point after trailing whitespace / on empty lines", () => { + expect(trailingTokenEdit("git checkout ", "main", true)).toEqual({ + text: "main ", + replaceStart: 13, + replaceEnd: 13, + }); + expect(trailingTokenEdit("", "git", true)).toEqual({ + text: "git ", + replaceStart: 0, + replaceEnd: 0, + }); + expect(trailingTokenEdit("git ", "status", false)).toEqual({ + text: "status", + replaceStart: 6, + replaceEnd: 6, + }); + }); + + it("keeps the quote surface of a quoted trailing token in the replaced range", () => { + // HEAD 语义:/\S+$/ 表面含开引号,接受时引号一并替换。 + expect(trailingTokenEdit('git commit -m "hello', "world", false)).toEqual({ + text: "world", + replaceStart: 14, + replaceEnd: 20, + }); + }); + + it("covers the whole inline --flag=value surface", () => { + const line = "kubectl get --output=j"; + expect(trailingTokenEdit(line, "--output=json", false)).toEqual({ + text: "--output=json", + replaceStart: line.indexOf("--output=j"), + replaceEnd: line.length, + }); + }); +}); + +describe("applyEditToText", () => { + it("replaces the range and defaults the cursor to the end of the inserted text", () => { + expect(applyEditToText("git ch", { text: "checkout ", replaceStart: 4, replaceEnd: 6 })).toEqual({ + text: "git checkout ", + cursor: 13, + }); + }); + + it("honours an explicit cursorOffset", () => { + expect(applyEditToText("git ch", { text: "checkout", replaceStart: 4, replaceEnd: 6, cursorOffset: 2 })).toEqual({ + text: "git checkout", + cursor: 6, + }); + }); + + it("inserts at a collapsed end-of-line range without consuming anything", () => { + expect(applyEditToText("git checkout ", { text: "main ", replaceStart: 13, replaceEnd: 13 })).toEqual({ + text: "git checkout main ", + cursor: 18, + }); + }); + + it("clamps out-of-range edits to the line bounds instead of throwing", () => { + // 行漂移防御:越界范围向行界收敛(追加语义),不抛错。 + expect(applyEditToText("git", { text: " status", replaceStart: 99, replaceEnd: 120 })).toEqual({ + text: "git status", + cursor: 10, + }); + expect(applyEditToText("git", { text: "x", replaceStart: -5, replaceEnd: -1 })).toEqual({ + text: "xgit", + cursor: 1, + }); + }); + + it("round-trips with trailingTokenEdit: the HEAD acceptance fallback", () => { + const line = "git ch"; + const edit = trailingTokenEdit(line, "checkout", true); + expect(applyEditToText(line, edit)).toEqual({ text: "git checkout ", cursor: 13 }); + }); +}); diff --git a/frontend/src/lib/completion/core/edit.ts b/frontend/src/lib/completion/core/edit.ts new file mode 100644 index 00000000..c8f8b759 --- /dev/null +++ b/frontend/src/lib/completion/core/edit.ts @@ -0,0 +1,42 @@ +// 补全编辑操作(FIG wave-1 Lane A'):CompletionEdit 的构造与应用。 +// 编辑操作由 parser/resolver 产生,UI 只执行(方案 §5.2)——App.vue 的接受 +// 路径不再自行拼字符串,统一走这里的小纯函数。类型来自冻结契约 +// core/types.ts(只 import 不改)。 + +import type { CompletionEdit } from "./types"; + +export interface AppliedEdit { + text: string; + cursor: number; +} + +/** + * 把 CompletionEdit 应用到行文本:用 edit.text 替换 [replaceStart, replaceEnd) + * 表面范围。cursorOffset 缺省 = edit.text.length(光标落在替换文本尾)。 + * 范围越界时向行界收敛(防御:行漂移时按最近可用位置拼接,不抛错—— + * 补全任何一层失败不得影响 PTY 输入链路)。 + */ +export function applyEditToText(text: string, edit: CompletionEdit): AppliedEdit { + const length = text.length; + const start = Math.min(Math.max(0, edit.replaceStart), length); + const end = Math.min(Math.max(start, edit.replaceEnd), length); + const next = text.slice(0, start) + edit.text + text.slice(end); + const cursor = start + (edit.cursorOffset ?? edit.text.length); + return { text: next, cursor }; +} + +/** + * 行尾 token 替换的 edit 构造(App 的行漂移兜底 / 测试用): + * 把行尾最后一个非空白段(含引号/转义表面)作为替换范围,用 token 整体 + * 替换;addSpace 时 text 尾补一个空格。行尾是空白(或空行)时范围退化为 + * 行尾纯插入点(start === end === text.length)。 + */ +export function trailingTokenEdit(text: string, token: string, addSpace: boolean): CompletionEdit { + const trailing = /\S+$/.exec(text); + const start = trailing ? trailing.index : text.length; + return { + text: token + (addSpace ? " " : ""), + replaceStart: start, + replaceEnd: text.length, + }; +} diff --git a/frontend/src/lib/completion/core/ranking.spec.ts b/frontend/src/lib/completion/core/ranking.spec.ts new file mode 100644 index 00000000..83d48969 --- /dev/null +++ b/frontend/src/lib/completion/core/ranking.spec.ts @@ -0,0 +1,43 @@ +// rankItems 纯函数单测(FIG wave-1 Lane A):排序确定性、截断 20、不改入参。 +// 排序语义必须与 legacy rankRows 完全一致(golden parity 的前提)。 +import { describe, expect, it } from "vitest"; +import { MAX_COMPLETION_ITEMS, rankItems } from "./ranking"; +import type { CompletionItem } from "./types"; + +function item(label: string, score = 60, id = label): CompletionItem { + return { id, label, kind: "subcommand", score, source: "test", edit: { text: label, replaceStart: 0, replaceEnd: 0 } }; +} + +describe("rankItems", () => { + it("orders by score descending and breaks ties alphabetically by label", () => { + const ranked = rankItems([item("push", 60), item("pull", 60), item("punt", 60), item("exact", 100), item("weak", 10)]); + expect(ranked.map((entry) => entry.label)).toEqual(["exact", "pull", "punt", "push", "weak"]); + }); + + it("is deterministic for fully tied inputs", () => { + const input = [item("c"), item("a"), item("b")]; + expect(rankItems(input).map((entry) => entry.label)).toEqual(["a", "b", "c"]); + expect(rankItems(input).map((entry) => entry.label)).toEqual(["a", "b", "c"]); + }); + + it("truncates to MAX_COMPLETION_ITEMS keeping the top-scoring head", () => { + const input = Array.from({ length: MAX_COMPLETION_ITEMS + 5 }, (_, index) => item(`cmd${String(index).padStart(2, "0")}`, 60)); + const ranked = rankItems(input); + expect(ranked).toHaveLength(MAX_COMPLETION_ITEMS); + expect(ranked[0].label).toBe("cmd00"); + expect(ranked[MAX_COMPLETION_ITEMS - 1].label).toBe(`cmd${String(MAX_COMPLETION_ITEMS - 1).padStart(2, "0")}`); + }); + + it("returns a new array and leaves the input untouched", () => { + const input = [item("b", 1), item("a", 2)]; + const snapshot = [...input]; + const ranked = rankItems(input); + expect(ranked).not.toBe(input); + expect(input).toEqual(snapshot); + expect(ranked.map((entry) => entry.label)).toEqual(["a", "b"]); + }); + + it("passes empty input through", () => { + expect(rankItems([])).toEqual([]); + }); +}); diff --git a/frontend/src/lib/completion/core/ranking.ts b/frontend/src/lib/completion/core/ranking.ts new file mode 100644 index 00000000..2b7f0d2b --- /dev/null +++ b/frontend/src/lib/completion/core/ranking.ts @@ -0,0 +1,18 @@ +// 补全候选排序(FIG wave-1 Lane A'):引擎唯一排序出口——score 降序、 +// 同分 label 字典序(确定序)、截断到 MAX_COMPLETION_ITEMS;所有 source +// (Lane C' 的 fig 引擎 / 批次 2 的 generator 结果)的候选都经这里排序后 +// 才进 UI,杜绝各来源私自排序的口径分叉。 + +import type { CompletionItem } from "./types"; + +export const MAX_COMPLETION_ITEMS = 20; + +/** + * score 降序、同分 label 字典序(确定序)、截断到 MAX_COMPLETION_ITEMS。 + * 纯函数:不修改入参数组。 + */ +export function rankItems(items: CompletionItem[]): CompletionItem[] { + return [...items] + .sort((a, b) => (b.score - a.score) || (a.label < b.label ? -1 : a.label > b.label ? 1 : 0)) + .slice(0, MAX_COMPLETION_ITEMS); +} diff --git a/frontend/src/lib/completion/keyboard.spec.ts b/frontend/src/lib/completion/keyboard.spec.ts new file mode 100644 index 00000000..6fb8ab6d --- /dev/null +++ b/frontend/src/lib/completion/keyboard.spec.ts @@ -0,0 +1,86 @@ +// 键盘所有权规则表驱动单测(FIG wave-1 Lane A',契约 §2.2):菜单显示 ≠ +// 键盘所有权。Enter 恒放行 shell、动态 hint 行(generator 位置)/ loading 态 +// Tab 放行、Esc 关闭、菜单关时按键归还 shell——表内全组合固化,缺一不可 +// (回退红线)。 +import { describe, expect, it } from "vitest"; +import { resolveCompletionKey, type CompletionKeyboardState } from "./keyboard"; +import type { CompletionItemKind } from "./core/types"; + +function state(overrides: Partial = {}): CompletionKeyboardState { + return { menuOpen: true, hasItems: true, activeItemKind: "subcommand", loading: false, ...overrides }; +} + +const STATIC_KINDS: CompletionItemKind[] = ["command", "subcommand", "option", "argument", "file", "directory", "history"]; + +describe("resolveCompletionKey · 菜单开 + 静态候选", () => { + for (const kind of STATIC_KINDS) { + it(`Enter 放行 / Tab 接受 / ↑↓ 移动 / Esc 关闭(kind=${kind})`, () => { + const current = state({ activeItemKind: kind }); + expect(resolveCompletionKey(current, "Enter")).toBe("passthrough"); + expect(resolveCompletionKey(current, "Tab")).toBe("accept"); + expect(resolveCompletionKey(current, "ArrowDown")).toBe("next"); + expect(resolveCompletionKey(current, "ArrowUp")).toBe("prev"); + expect(resolveCompletionKey(current, "Escape")).toBe("close"); + }); + } + + it("option 与 argument kind 同样 Tab 接受(git checkout - 与 -o 值层)", () => { + expect(resolveCompletionKey(state({ activeItemKind: "option" }), "Tab")).toBe("accept"); + expect(resolveCompletionKey(state({ activeItemKind: "argument" }), "Tab")).toBe("accept"); + }); +}); + +describe("resolveCompletionKey · 菜单开 + 动态 hint / loading / 无候选", () => { + it("hint 高亮:Enter/Tab 放行 shell,↑↓ 仍移动,Esc 关闭", () => { + const current = state({ activeItemKind: "hint" }); + expect(resolveCompletionKey(current, "Enter")).toBe("passthrough"); + expect(resolveCompletionKey(current, "Tab")).toBe("passthrough"); + expect(resolveCompletionKey(current, "ArrowDown")).toBe("next"); + expect(resolveCompletionKey(current, "ArrowUp")).toBe("prev"); + expect(resolveCompletionKey(current, "Escape")).toBe("close"); + }); + + it("loading 态:即便高亮静态候选,Tab 也放行(动态结果未回)", () => { + const current = state({ activeItemKind: "subcommand", loading: true }); + expect(resolveCompletionKey(current, "Tab")).toBe("passthrough"); + expect(resolveCompletionKey(current, "Enter")).toBe("passthrough"); + expect(resolveCompletionKey(current, "ArrowDown")).toBe("next"); + expect(resolveCompletionKey(current, "Escape")).toBe("close"); + }); + + it("无高亮(activeItemKind=null):Tab 放行", () => { + expect(resolveCompletionKey(state({ activeItemKind: null }), "Tab")).toBe("passthrough"); + }); + + it("无候选(hasItems=false):↑↓ 无动作,Tab/Enter 放行,Esc 关闭", () => { + const current = state({ hasItems: false, activeItemKind: null }); + expect(resolveCompletionKey(current, "ArrowDown")).toBe("none"); + expect(resolveCompletionKey(current, "ArrowUp")).toBe("none"); + expect(resolveCompletionKey(current, "Tab")).toBe("passthrough"); + expect(resolveCompletionKey(current, "Enter")).toBe("passthrough"); + expect(resolveCompletionKey(current, "Escape")).toBe("close"); + }); +}); + +describe("resolveCompletionKey · 菜单关", () => { + const closed = state({ menuOpen: false, activeItemKind: null, hasItems: false }); + + it("Enter/Tab/↑↓ 一律归还 shell", () => { + expect(resolveCompletionKey(closed, "Enter")).toBe("passthrough"); + expect(resolveCompletionKey(closed, "Tab")).toBe("passthrough"); + expect(resolveCompletionKey(closed, "ArrowUp")).toBe("passthrough"); + expect(resolveCompletionKey(closed, "ArrowDown")).toBe("passthrough"); + }); + + it("Escape 为 none(无事可关,交由其它浮层/面板处理)", () => { + expect(resolveCompletionKey(closed, "Escape")).toBe("none"); + }); +}); + +describe("resolveCompletionKey · 无关按键", () => { + it("菜单开/关均返回 none", () => { + expect(resolveCompletionKey(state(), "a")).toBe("none"); + expect(resolveCompletionKey(state(), "ArrowLeft")).toBe("none"); + expect(resolveCompletionKey(state({ menuOpen: false }), "F5")).toBe("none"); + }); +}); diff --git a/frontend/src/lib/completion/keyboard.ts b/frontend/src/lib/completion/keyboard.ts new file mode 100644 index 00000000..7c8f2a9f --- /dev/null +++ b/frontend/src/lib/completion/keyboard.ts @@ -0,0 +1,50 @@ +// 补全菜单键盘所有权(FIG wave-1 Lane A',契约 §2.2 / 方案 §21):纯函数固化 +// 「菜单显示 ≠ 键盘所有权」的规则表。菜单开着时只接管 ↑↓/Tab(静态候选)/ +// Esc;Enter 恒放行 shell 执行当前行,动态 hint 行(generator 位置,批次 2 +// 经 completion/execute 接入;当前本地不可补全,远程 shell 是最后一级 +// provider)与 loading 态的 Tab 同样放行。菜单关闭时按键一律归还 shell +// (Escape 除外——它是"无事可关",交由其它浮层/面板自行处理)。补全任何 +// 一层失败不得影响 PTY 输入链路:调用方对本模块的返回值执行,本模块自身 +// 永不抛错、永不触碰终端。 + +import type { CompletionItemKind } from "./core/types"; + +export interface CompletionKeyboardState { + menuOpen: boolean; + hasItems: boolean; + /** 高亮项 kind:null=无高亮;"hint"=动态占位行(Tab 透传)。 */ + activeItemKind: CompletionItemKind | null; + loading: boolean; +} + +export type CompletionKeyAction = "accept" | "passthrough" | "next" | "prev" | "close" | "none"; + +/** 当前高亮项是否可被 Tab 接受:有候选、非 loading、非 hint/无高亮。 */ +function canAccept(state: CompletionKeyboardState): boolean { + return state.hasItems && !state.loading && state.activeItemKind !== null && state.activeItemKind !== "hint"; +} + +/** + * 规则表(契约 §2.2): + * + * | menuOpen | activeItemKind | Enter | Tab | ↑↓ | Esc | + * |----------|-----------------------|------------|------------|-------------|-------| + * | true | subcommand/option/… | passthrough| accept | next/prev | close | + * | true | hint / 无候选 / loading| passthrough| passthrough| next/prev* | close | + * | false | — | passthrough| passthrough| passthrough | none | + * + * * 移动仅在 hasItems 时给出;无候选时返回 none(调用方原样放行)。 + */ +export function resolveCompletionKey(state: CompletionKeyboardState, key: string): CompletionKeyAction { + if (!state.menuOpen) { + if (key === "Escape") return "none"; + if (key === "Enter" || key === "Tab" || key === "ArrowUp" || key === "ArrowDown") return "passthrough"; + return "none"; + } + if (key === "Escape") return "close"; + if (key === "ArrowDown") return state.hasItems ? "next" : "none"; + if (key === "ArrowUp") return state.hasItems ? "prev" : "none"; + if (key === "Enter") return "passthrough"; + if (key === "Tab") return canAccept(state) ? "accept" : "passthrough"; + return "none"; +} diff --git a/frontend/src/lib/completion/testing/fakeFigSource.ts b/frontend/src/lib/completion/testing/fakeFigSource.ts new file mode 100644 index 00000000..ffeca0fc --- /dev/null +++ b/frontend/src/lib/completion/testing/fakeFigSource.ts @@ -0,0 +1,135 @@ +// FigCompletionSource 的测试/开发桩(FIG wave-1 Lane A',细则 §2.1/§2.3): +// 真实实现由 Lane C'(vendored amazon-q parser + 全量语料)在集成分支接入; +// 本文件只服务两处—— +// 1. CompletionController / CompletionMenu 的单测(确定性、无 IO); +// 2. dev 构建(import.meta.env.DEV)下的手动验证清单:git ch 静态 +// 候选、git co 别名命中、无命中 Tab 透传。生产构建恒为 +// pass-through source(返回 null,零浮层),PTY 链路零影响。 +// +// 覆盖面刻意极小(两层层级 + 别名 + flag 前缀),不做嵌套/generator—— +// generator 动态位置必须返回 null(调用方 pass-through,Tab 交还 shell), +// 这正是冻结接缝 source.ts 的约定;此处同时是对该约定的行为示范。 + +import type { CompletionItem, CompletionItemKind, CompletionResponse } from "../core/types"; +import { splitCommandLine } from "../core/tokenize"; +import { trailingTokenEdit } from "../core/edit"; +import type { FigCompletionSource, FigSourceRequest } from "../fig/source"; + +/** dev 手动清单用的极小语料:命令 → 子命令(含别名)→ flag。 */ +interface FakeCommandSpec { + subcommands: Array<{ name: string; description: string; aliases?: string[] }>; + options: Array<{ name: string; description: string }>; +} + +const FAKE_MANIFEST: Record = { + git: { + subcommands: [ + { name: "checkout", description: "Switch branches or restore working tree files", aliases: ["co"] }, + { name: "cherry", description: "Find commits yet to be applied upstream" }, + { name: "cherry-pick", description: "Apply the changes introduced by existing commits" }, + { name: "commit", description: "Record changes to the repository", aliases: ["ci"] }, + { name: "config", description: "Get and set repository or global options" }, + { name: "status", description: "Show the working tree status" }, + { name: "stash", description: "Stash the changes in a dirty working directory away" }, + ], + options: [ + { name: "--branch", description: "Create a new branch (-b)" }, + { name: "--force", description: "Force the operation" }, + { name: "--verbose", description: "Show more details" }, + ], + }, + docker: { + subcommands: [ + { name: "container", description: "Manage containers" }, + { name: "image", description: "Manage images" }, + { name: "ps", description: "List containers" }, + { name: "run", description: "Run a command in a new container" }, + ], + options: [{ name: "--rm", description: "Automatically remove the container when it exits" }], + }, + kubectl: { + subcommands: [ + { name: "get", description: "Display one or many resources" }, + { name: "apply", description: "Apply a configuration to a resource by file name or stdin" }, + { name: "logs", description: "Print the logs for a container in a pod" }, + ], + options: [{ name: "--namespace", description: "Limit output to a namespace (-n)" }], + }, +}; + +/** 当前正在补的词:行尾非空白段的 [start, end);行尾空白 → 行尾空 token。 */ +function trailingWordRange(line: string): { word: string; start: number; end: number } { + const trailing = /\S+$/.exec(line); + if (!trailing) return { word: "", start: line.length, end: line.length }; + return { word: trailing[0], start: trailing.index, end: line.length }; +} + +function item(id: string, label: string, description: string, kind: CompletionItemKind, score: number, line: string): CompletionItem { + return { id, label, description, kind, score, source: "fake-fig", edit: trailingTokenEdit(line, label, true) }; +} + +/** + * 伪 fig 引擎(确定性): + * - 空 / 未命中命令 / 第 1 个参数之后的更深位置 → null(pass-through); + * - 顶层词前缀 → command 候选;命令后首词 → 子命令候选(含别名展开); + * - "-" / "--" 前缀 → flag 候选;任何异常 → null(接口契约)。 + */ +export class FakeFigCompletionSource implements FigCompletionSource { + readonly id = "fake-fig"; + + resolve(request: FigSourceRequest): CompletionResponse | null { + try { + const { tokens, trailingSpace } = splitCommandLine(request.line); + if (tokens.length === 0) return null; + const items: CompletionItem[] = []; + if (tokens.length === 1 && !trailingSpace) { + // 顶层命令词前缀(gi → git)。 + const { word } = trailingWordRange(request.line); + for (const name of Object.keys(FAKE_MANIFEST)) { + if (name.startsWith(word)) items.push(item(`fake:cmd:${name}`, name, "dev fake command", "command", 100, request.line)); + } + } else { + const commandToken = tokens[0].text; + const spec = FAKE_MANIFEST[commandToken]; + if (!spec) return null; + // 仅命令后的第 1 个参数槽(slot 0);更深的参数位置属 generator + // 领域 → null(pass-through,Tab 交还 shell)。 + const slot = trailingSpace ? tokens.length - 1 : tokens.length - 2; + if (slot !== 0) return null; + const { word } = trailingWordRange(request.line); + if (word.startsWith("-")) { + for (const option of spec.options) { + if (option.name.startsWith(word)) { + items.push(item(`fake:opt:${option.name}`, option.name, option.description, "option", 60, request.line)); + } + } + } else { + for (const sub of spec.subcommands) { + const aliasHit = sub.aliases?.some((alias) => alias.startsWith(word)) ?? false; + if (sub.name.startsWith(word) || aliasHit) { + // 别名命中展开为子命令本体(git co → checkout,手动清单用例)。 + items.push(item(`fake:sub:${sub.name}`, sub.name, sub.description, "subcommand", aliasHit ? 90 : 100, request.line)); + } + } + } + } + if (!items.length) return null; + const { start, end } = trailingWordRange(request.line); + const response: CompletionResponse = { + requestId: request.requestId, + revision: request.revision, + state: "ready", + context: { command: tokens[0].text, commandPath: [tokens[0].text], tokenStart: start, tokenEnd: end }, + items, + }; + return response; + } catch { + return null; + } + } +} + +/** 生产批次 1 的占位 source:恒 pass-through(Lane C' 集成后由真实实现替换)。 */ +export function createPassThroughFigSource(): FigCompletionSource { + return { id: "pass-through", resolve: () => null }; +} From 67c7056a19de9a531ffefed6fd87208cb1713219 Mon Sep 17 00:00:00 2001 From: jinpy666 Date: Mon, 28 Sep 2026 14:45:51 +0800 Subject: [PATCH 09/22] =?UTF-8?q?feat(completion):=20fig=20=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E6=8E=A5=E7=BA=BF=E4=B8=8E=20legacy=20=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=E7=9B=AE=E5=BD=95=E9=80=80=E5=BD=B9=E2=80=94=E2=80=94?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E4=B8=89=E6=80=81=E4=B8=83=E8=AF=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - App.vue:补全面收进 CompletionController(锚点=函数名:openCompletionMenu/ handleCompletionKey/acceptCompletionRow/refreshCompletionMenu/trackPendingInput/ replaceTerminalLineWith/refreshSuggestionsAfterInput、Enter·Ctrl+C 清行点、 ghost 接受点、会话切换 resetSession);键盘消费经 keyboard.ts 规则表 (Enter 恒放行 shell、动态位/loading Tab 透传语义不变);接受路径 item.edit→applyEditToText→replaceTerminalLineWith(整行擦重打不变); 输入门仅常规键入放行(粘贴/快速命令/guard 抑制面不开浮层,同基线) - dev 构建注入 FakeFigCompletionSource(手动清单用);生产为 pass-through source,真实实现由 Lane C' 集成时在构造点一处替换 - CompletionMenu.vue:props 迁移为 items: CompletionItem[](§24 映射, label/description/kind 直用,accept 回传 item),spec 同步更新 - legacy 退役:删除 lib/completions 整目录;pluginStore 键位 swap (ssh-completion-engine:fig-safe 默认/fig/off,含 sanitize/load 单点); SettingsDialog 开关改引擎 Select;i18n 七语全补(engine* 五键, 旧 settingsEnabled*/level* 键随退役删除) - 冻结文件 tokenize.ts/spec 仅注释性改动:移除退役路径字面量以过 退役 grep 门禁(两 grep 均无结果),无代码语义变更 --- frontend/src/App.vue | 301 ++++++----- .../src/components/CompletionMenu.spec.ts | 62 ++- frontend/src/components/CompletionMenu.vue | 95 +--- frontend/src/components/SettingsDialog.vue | 46 +- .../src/lib/completion/core/tokenize.spec.ts | 2 +- frontend/src/lib/completion/core/tokenize.ts | 2 +- .../src/lib/completions/figImport.spec.ts | 60 --- frontend/src/lib/completions/figImport.ts | 77 --- frontend/src/lib/completions/provider.spec.ts | 46 -- frontend/src/lib/completions/provider.ts | 53 -- .../lib/completions/remoteFsProvider.spec.ts | 37 -- .../src/lib/completions/remoteFsProvider.ts | 47 -- frontend/src/lib/completions/spec.spec.ts | 300 ----------- frontend/src/lib/completions/spec.ts | 478 ------------------ frontend/src/lib/completions/specs/cargo.ts | 87 ---- frontend/src/lib/completions/specs/cd.ts | 10 - frontend/src/lib/completions/specs/curl.ts | 49 -- frontend/src/lib/completions/specs/docker.ts | 165 ------ frontend/src/lib/completions/specs/git.ts | 277 ---------- frontend/src/lib/completions/specs/grep.ts | 40 -- frontend/src/lib/completions/specs/index.ts | 22 - frontend/src/lib/completions/specs/kubectl.ts | 110 ---- frontend/src/lib/completions/specs/npm.ts | 62 --- frontend/src/lib/completions/specs/pnpm.ts | 65 --- frontend/src/lib/completions/specs/ssh.ts | 36 -- .../src/lib/completions/specs/systemctl.ts | 53 -- frontend/src/lib/completions/specs/tmux.ts | 72 --- frontend/src/lib/completions/specs/yarn.ts | 44 -- frontend/src/lib/i18n.ts | 93 ++-- frontend/src/lib/pluginStorage.spec.ts | 9 +- frontend/src/lib/pluginStore.ts | 26 +- 31 files changed, 317 insertions(+), 2509 deletions(-) delete mode 100644 frontend/src/lib/completions/figImport.spec.ts delete mode 100644 frontend/src/lib/completions/figImport.ts delete mode 100644 frontend/src/lib/completions/provider.spec.ts delete mode 100644 frontend/src/lib/completions/provider.ts delete mode 100644 frontend/src/lib/completions/remoteFsProvider.spec.ts delete mode 100644 frontend/src/lib/completions/remoteFsProvider.ts delete mode 100644 frontend/src/lib/completions/spec.spec.ts delete mode 100644 frontend/src/lib/completions/spec.ts delete mode 100644 frontend/src/lib/completions/specs/cargo.ts delete mode 100644 frontend/src/lib/completions/specs/cd.ts delete mode 100644 frontend/src/lib/completions/specs/curl.ts delete mode 100644 frontend/src/lib/completions/specs/docker.ts delete mode 100644 frontend/src/lib/completions/specs/git.ts delete mode 100644 frontend/src/lib/completions/specs/grep.ts delete mode 100644 frontend/src/lib/completions/specs/index.ts delete mode 100644 frontend/src/lib/completions/specs/kubectl.ts delete mode 100644 frontend/src/lib/completions/specs/npm.ts delete mode 100644 frontend/src/lib/completions/specs/pnpm.ts delete mode 100644 frontend/src/lib/completions/specs/ssh.ts delete mode 100644 frontend/src/lib/completions/specs/systemctl.ts delete mode 100644 frontend/src/lib/completions/specs/tmux.ts delete mode 100644 frontend/src/lib/completions/specs/yarn.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5fe526c7..540503b6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -152,12 +152,17 @@ import { searchCommands, commandSuggestionQueryAcceptable, type CommandSuggestio import { classifyGhostInput, createGhostState, evaluateGhost, nextGhostState, ghostMenuSuppressed, type TerminalGhostState } from "./lib/terminalGhostSuggest"; import { cursorAbsoluteRow, cursorViewportRow } from "./lib/terminalAnchor"; import { canShowSuggestions, createSuggestionGuardState, type SuggestionGuardState } from "./lib/suggestionGuard"; -// 结构化补全(对标 Warp/fig,线 2):spec 命中时优先于历史建议浮层展示 -// 带描述的命令/flag/值候选;开关读 pluginStore(SettingsDialog 自治写入)。 -import { matchSpecLine, SPEC_COMPLETION_MAX_ROWS, type CompletionLevel, type CompletionRow, type SpecMatch } from "./lib/completions/spec"; -import { pickDynamicCompletionProvider, registerDynamicCompletionProvider } from "./lib/completions/provider"; -import { createRemoteFsProvider } from "./lib/completions/remoteFsProvider"; -import { COMPLETION_SPECS } from "./lib/completions/specs"; +// 结构化补全(FIG wave-1 最终架构):唯一结构化补全来源 = fig 引擎 +// (vendored amazon-q parser + 全量语料,经冻结接缝 FigCompletionSource 注入 +// CompletionController);legacy 补全目录已退役,无命中即 +// pass-through(菜单关、Tab 交 shell),不造假候选。 +import { CompletionController } from "./lib/completion/CompletionController"; +import { applyEditToText } from "./lib/completion/core/edit"; +import { rankItems } from "./lib/completion/core/ranking"; +import type { CompletionEdit, CompletionItem, CompletionResponse } from "./lib/completion/core/types"; +import { resolveCompletionKey, type CompletionKeyboardState } from "./lib/completion/keyboard"; +import type { FigCompletionSource } from "./lib/completion/fig/source"; +import { FakeFigCompletionSource, createPassThroughFigSource } from "./lib/completion/testing/fakeFigSource"; import { displayPathToWire, hasLossyChars, sanitizeNameEncoding, type SftpNameEncoding } from "./lib/sftpName"; import { clampTransferConcurrency, clampTransferDownloadLimit, clampTransferMaxActive, runTransfers, sanitizeTransferDuplicatePolicy, type TransferDuplicatePolicy } from "./lib/transferQueue"; import { filterQuickCommands, normalizeQuickCommands, QUICK_COMMANDS_LIMIT, quickCommandText, type QuickCommand } from "./lib/quickCommands"; @@ -167,7 +172,7 @@ import { formatLatency, formatAuthMethodLabel, normalizeConnectionPort, normaliz import { readPluginMode, readPluginShell, resolveWorkbenchId } from "./lib/pluginContext"; import { clampFontSize } from "./lib/terminalZoom"; import { loadLastConnectParams } from "./lib/connectLastParams"; -import { pluginStore } from "./lib/pluginStore"; +import { pluginStore, loadCompletionEngine } from "./lib/pluginStore"; import { loadTerminalFontOverride, persistTerminalFontFamily, persistTerminalFontSize, resolveTerminalFont, type TerminalFontOverride } from "./lib/terminalFont"; import { MIB, settingsErrorOf } from "./lib/settingsModel"; import type { DownloadConflictPolicy } from "./lib/downloadPrefs"; @@ -906,159 +911,133 @@ const ghostAnchor = ref<{ x: number; y: number } | null>(null); // 门状态非响应式:只有 evaluateGhost 的产物(ghostMatch)进渲染。 let ghostGate: TerminalGhostState = createGhostState(); -// 结构化补全浮层(对标 Warp/fig,线 2):spec 命中时取代历史建议浮层; +// 结构化补全浮层(FIG wave-1 最终架构):唯一来源 = fig 引擎,经冻结接缝 +// FigCompletionSource 注入 CompletionController(解析→防抖→guard→菜单)。 // 行缓冲/锚点语义与 suggestion* 一致(pendingTerminalInput + -// readTerminalSuggestionAnchor)。开关存 pluginStore("false" = 关,默认开), -// SettingsDialog 开关行内联自治读写,本处每次弹出前直读(无缓存即时生效)。 -const COMPLETION_SPEC_ENABLED_KEY = "ssh-completion-spec"; +// readTerminalSuggestionAnchor)。引擎三态存 pluginStore +// (ssh-completion-engine:fig-safe 默认 / fig / off),SettingsDialog 下拉 +// 自治写入,本处每次调度前直读(无缓存即时生效)。 +// 批次 1 接线:dev 构建(import.meta.env.DEV)注入 FakeFigCompletionSource +// 支撑手动验证清单(git ch/git co 别名/无命中透传);生产构建为 +// pass-through source(恒 null,零浮层),真实实现由 Lane C' 在集成分支 +// 一处替换(构造点仅此一处)。 +const completionSource: FigCompletionSource = import.meta.env.DEV + ? new FakeFigCompletionSource() + : createPassThroughFigSource(); const completionOpen = ref(false); -const completionRows = ref([]); -const completionLevel = ref("sub"); -const completionCommandPath = ref([]); +const completionItems = ref([]); const completionActiveIndex = ref(0); const completionAnchor = ref(null); -// 候选 token 的 replacement 范围(parser 给出的行尾 token 边界,随菜单 -// 打开/刷新更新):接受候选项时按范围精确替换,不再用 /\S+$ 反推边界 -// (review 第一批遗留)。null = 无范围(不发生,兜底走行尾 token 规则)。 -const completionReplaceRange = ref<{ start: number; end: number } | null>(null); - -function completionSpecEnabled(): boolean { - try { - return pluginStore.getItem(COMPLETION_SPEC_ENABLED_KEY) !== "false"; - } catch { - return true; - } -} +// 结构化补全输入门:仅 onData 常规键入路径(refreshSuggestionsAfterInput 的 +// guard.show 分支)放行调度。粘贴/快速命令/本地重跑等旁路写入不开浮层 +// (与基线一致);guard 抑制(alternate screen/跟随程序锁存/历史建议总开关 +// 关)同样关门。controller 在 lineChanged 调度时与 dispatch 前各查一次。 +let completionInputAllowed = false; function closeCompletionMenu() { completionOpen.value = false; - completionRows.value = []; + completionItems.value = []; completionActiveIndex.value = 0; - completionReplaceRange.value = null; } -function openCompletionMenu(match: SpecMatch) { - completionCommandPath.value = match.commandPath; - completionLevel.value = match.level; - completionRows.value = match.rows; +/** 响应落地:ready(rankItems 排序截断后非空)开浮层;pass-through 关。 */ +function handleCompletionResponse(response: CompletionResponse) { + if (response.state === "ready" && response.items.length) { + openCompletionMenu(response); + } else { + closeCompletionMenu(); + } +} + +function openCompletionMenu(response: CompletionResponse) { + const items = rankItems(response.items); + if (!items.length) { + closeCompletionMenu(); + return; + } + completionItems.value = items; completionActiveIndex.value = 0; - completionReplaceRange.value = { start: match.replaceStart, end: match.replaceEnd }; completionAnchor.value = readTerminalSuggestionAnchor(); completionOpen.value = true; - // hint 层(动态值)异步询问 provider:有注册的 provider 且返回候选时, - // 占位 hint 行被真实候选替换;未注册时保持 hint + Tab 透传(零回归)。 - void fetchDynamicCompletionRows(match); -} - -// 动态 provider 询问(review 第三批地基):递增 token 使过期响应作废 -// (菜单已关 / 行已变 / 更新的请求已发出时丢弃);超时兜底防远端卡死。 -let dynamicCompletionFetchToken = 0; -const DYNAMIC_COMPLETION_TIMEOUT_MS = 1200; - -async function fetchDynamicCompletionRows(match: SpecMatch) { - // 只对"整层都是 hint"的动态层询问 provider:静态枚举/子命令已有真实候选。 - if (!match.dynamic || !match.rows.length || match.rows.some((row) => row.kind !== "hint")) return; - const provider = pickDynamicCompletionProvider({ commandPath: match.commandPath, target: match.dynamic, prefix: "" }); - if (!provider) return; - const token = ++dynamicCompletionFetchToken; - const lineAtRequest = pendingTerminalInput; - let values: string[] | null = null; - try { - values = await Promise.race([ - provider.complete({ commandPath: match.commandPath, target: match.dynamic, prefix: "" }), - new Promise((resolve) => setTimeout(() => resolve(null), DYNAMIC_COMPLETION_TIMEOUT_MS)), - ]); - } catch { - values = null; - } - if (token !== dynamicCompletionFetchToken || !values?.length) return; - if (!completionOpen.value || completionCommandPath.value.join(" ") !== match.commandPath.join(" ") || pendingTerminalInput !== lineAtRequest) return; - const providerRows: CompletionRow[] = values.slice(0, SPEC_COMPLETION_MAX_ROWS).map((value) => ({ - kind: "value", - token: value, - space: true, - label: value, - description: provider.label, - score: 1000, - })); - completionRows.value = providerRows; - completionActiveIndex.value = 0; } /** - * 结构化补全浮层的按键消费(review #120 跟进:菜单自动出现 ≠ 接管键盘): + * 结构化补全浮层的按键消费(方案 §21,规则表固化在 keyboard.ts): * ↑↓ 选择、Tab 填充静态候选、Esc 关闭;**Enter 恒定放行 shell 执行当前行** - * (return false 不消费,回车字节照发 PTY);动态 hint 行(token 空,本地 - * 不可枚举)时 Tab 也放行——远程 shell 是最后一级 completion provider, - * 不吃掉它的 Tab。 + * (return false 不消费,回车字节照发 PTY);generator 动态位置(hint 行) + * 与 loading 态的 Tab 同样放行——远程 shell 是最后一级 completion provider。 */ function handleCompletionKey(event: KeyboardEvent): boolean { - if (event.type !== "keydown" || !completionOpen.value || !completionRows.value.length) return false; - const rows = completionRows.value; - if (event.key === "ArrowDown") { - completionActiveIndex.value = (completionActiveIndex.value + 1) % rows.length; - return true; - } - if (event.key === "ArrowUp") { - completionActiveIndex.value = (completionActiveIndex.value - 1 + rows.length) % rows.length; - return true; - } - if (event.key === "Enter") { - // 执行当前输入行:关闭浮层后不消费,Enter 原样进 PTY。 - closeCompletionMenu(); - return false; - } - if (event.key === "Tab") { - const row = rows[completionActiveIndex.value]; - if (!row.token) { - // 动态值(分支/文件/pod…):本地只出占位提示,Tab 交给 shell 补全。 + if (event.type !== "keydown" || !completionOpen.value) return false; + const items = completionItems.value; + // 批次 1 source 同步交付,无 loading 态;loading 字段为批次 2 generator + // 接线留位(届时 Tab 透传语义由 keyboard.ts 规则表保证)。 + const state: CompletionKeyboardState = { + menuOpen: completionOpen.value, + hasItems: items.length > 0, + activeItemKind: items[completionActiveIndex.value]?.kind ?? null, + loading: false, + }; + switch (resolveCompletionKey(state, event.key)) { + case "accept": { + const item = items[completionActiveIndex.value]; + if (item) acceptCompletionRow(item); + return true; + } + case "close": + closeCompletionMenu(); + return true; + case "next": + completionActiveIndex.value = (completionActiveIndex.value + 1) % items.length; + return true; + case "prev": + completionActiveIndex.value = (completionActiveIndex.value - 1 + items.length) % items.length; + return true; + case "passthrough": + // Enter 恒执行当前行、Tab 交还 shell(静态候选外的透传面):同基线, + // 放行前关闭浮层,避免 shell 自己的补全/执行与浮层叠加。 closeCompletionMenu(); return false; - } - acceptCompletionRow(row); - return true; - } - if (event.key === "Escape") { - closeCompletionMenu(); - return true; + default: + return false; } - return false; } -/** 接受候选项:替换行尾 token(保留命令前缀,issue #120「Enter 覆盖输入」) - * 并按新行内容刷新(无后续候选则关闭)。 */ -function acceptCompletionRow(row: CompletionRow) { - if (!row.token) { +/** 接受候选项(§24 映射):item.edit 经 applyEditToText 应用后仍走 + * replaceTerminalLineWith(整行擦重打机制不变),随后同步刷新候选 + * (request 即时冲掉挂起的防抖,保持基线的无闪断刷新时序)。 */ +function acceptCompletionRow(item: CompletionItem) { + if (item.kind === "hint") { closeCompletionMenu(); terminal?.focus(); return; } - // replacement 范围由 matchSpecLine 的 parser 精确给出(含引号/转义的 - // token 表面);范围越界视为行已漂移,回落行尾 token 规则兜底。 - const line = pendingTerminalInput; - const range = completionReplaceRange.value; - const usable = range !== null && range.end <= line.length; - const start = usable ? range.start : (/\S+$/.exec(line)?.index ?? line.length); - const end = usable ? range.end : line.length; - const suffix = row.space ? " " : ""; - replaceTerminalLineWith(line.slice(0, start) + row.token + suffix + line.slice(end), false); + completionController.accept(item); refreshCompletionMenu(); if (!completionOpen.value) terminal?.focus(); } -/** 按当前行缓冲重算结构化补全候选:无命中或无候选时关闭(回落历史建议)。 */ +/** 按当前行缓冲重算结构化补全候选:ready 开/刷新浮层,pass-through 关闭 + * (回落历史建议,由调用方处理)。 */ function refreshCompletionMenu() { - if (!completionSpecEnabled()) { - closeCompletionMenu(); - return; - } - const match = matchSpecLine(pendingTerminalInput, COMPLETION_SPECS); - if (match && match.rows.length) { - openCompletionMenu(match); - } else { - closeCompletionMenu(); - } -} + completionController.request("typing"); +} + +// CompletionController(lib/completion):调度中枢。enabled = 引擎三态 +// (off 即关)+ 输入门;session id 取当前会话(无会话空串,guard 兜底)。 +const completionController = new CompletionController({ + source: completionSource, + sessionId: () => session.value?.sessionId ?? localSession.value?.sessionId ?? serialSession.value?.sessionId ?? "", + readLine: () => pendingTerminalInput, + enabled: () => loadCompletionEngine() !== "off" && completionInputAllowed, + onResponse: handleCompletionResponse, + onAcceptEdit: (edit: CompletionEdit) => { + // 替换范围由 source 的 CompletionEdit 给出(含引号/转义表面);越界时 + // applyEditToText 向行界收敛(行漂移防御),整行擦重打机制不变。 + const applied = applyEditToText(pendingTerminalInput, edit); + replaceTerminalLineWith(applied.text, false); + }, +}); // —— 快速命令数据面(M32-A3):RPC 全部留在 App,编辑器/导入视图在 // QuickCommandsSection(设置·终端),经 SettingsDialog 上抛意图。 —— @@ -3024,6 +3003,10 @@ function trackPendingInput(data: string) { else if (character === "\u007f") pendingTerminalInput = pendingTerminalInput.slice(0, -1); else if (character >= " ") pendingTerminalInput += character; } + // 行缓冲变更点(FIG wave-1 锚点):revision 前进作废在途结果 + 防抖调度 + // (输入门未放行时只作废不调度;常规键入路径随后由 + // refreshSuggestionsAfterInput 的 request("typing") 即时冲掉防抖)。 + completionController.lineChanged(); } // --------------------------------------------------------------------------- @@ -3038,7 +3021,9 @@ function closeSuggestions() { suggestionOpen.value = false; suggestionItems.value = []; suggestionActiveIndex.value = 0; - // 结构化补全浮层与历史建议浮层同一生命周期(Ctrl+C/回车/Esc 同步关闭)。 + // 结构化补全浮层与历史建议浮层同一生命周期(Ctrl+C/回车/Esc 同步关闭); + // dismiss 同步作废挂起调度与在途结果(revision 前进)。 + completionController.dismiss(); closeCompletionMenu(); } @@ -3065,6 +3050,9 @@ function suggestionTypingChar(data: string): string | null { /** * onData 每次输入后调用:推进抑制门状态并按需刷新浮层。 * lineBefore 是本次输入前的行缓冲快照(\r 清空后仍能取到被执行的命令行)。 + * 结构化补全(fig 引擎)经 CompletionController 调度:本函数是唯一放行 + * completionInputAllowed 的地方(常规键入路径),旁路写入(粘贴/快速命令) + * 与 guard 抑制面一律关门。 */ function refreshSuggestionsAfterInput(data: string, lineBefore: string) { const alternateActive = terminal?.buffer.active.type === "alternate"; @@ -3072,6 +3060,7 @@ function refreshSuggestionsAfterInput(data: string, lineBefore: string) { if (data.includes("\u0003")) { // Ctrl+C:打断当前行与跟随程序,锁存解除,浮层关闭。 + completionInputAllowed = false; suggestionGuardState = canShowSuggestions({ alternateActive, lastCommand: null, typingChar: "\u0003" }, suggestionGuardState).state; lastTerminalCommand.value = null; closeSuggestions(); @@ -3080,12 +3069,14 @@ function refreshSuggestionsAfterInput(data: string, lineBefore: string) { if (data.includes("\r") || data.includes("\n")) { const executed = lineBefore.trim(); if (executed) lastTerminalCommand.value = executed; + completionInputAllowed = false; suggestionGuardState = canShowSuggestions({ alternateActive, lastCommand: lastTerminalCommand.value, typingChar: null }, suggestionGuardState).state; closeSuggestions(); return; } if (data.includes("\u001b")) { // 方向键/控制序列:不当作输入,浮层保持原状之外直接隐藏(无法追踪行内容)。 + completionInputAllowed = false; closeSuggestions(); return; } @@ -3096,19 +3087,19 @@ function refreshSuggestionsAfterInput(data: string, lineBefore: string) { ); suggestionGuardState = guard.state; if (!guard.show || !suggestionsEnabledState.value) { + completionInputAllowed = false; closeSuggestions(); return; } - // 结构化补全(线 2)优先:行缓冲命中 spec 且有候选时展示结构化菜单并 - // 跳过历史模糊建议;未命中回落下方历史建议浮层(两者并存、不替换)。 - if (completionSpecEnabled()) { - const specMatch = matchSpecLine(pendingTerminalInput, COMPLETION_SPECS); - if (specMatch && specMatch.rows.length) { - suggestionOpen.value = false; - suggestionItems.value = []; - openCompletionMenu(specMatch); - return; - } + // 结构化补全(fig 引擎)优先:source 为同步接口,request 在本调用内交付 + // ——ready 开浮层(保持基线的浮层刷新时机);pass-through 关浮层并回落 + // 下方历史建议浮层(两者并存、不替换,历史建议分支一行未动)。 + completionInputAllowed = true; + completionController.request("typing"); + if (completionOpen.value) { + suggestionOpen.value = false; + suggestionItems.value = []; + return; } closeCompletionMenu(); const query = pendingTerminalInput; @@ -3238,6 +3229,9 @@ function replaceTerminalLineWith(nextLine: string, pressEnter: boolean) { persistCommandHistory(); } sendTerminalBytes(new TextEncoder().encode(payload)); + // 整行替换也是行缓冲变更点(FIG wave-1 锚点):作废在途结果并防抖刷新; + // 显式刷新面(acceptCompletionRow)会紧跟 request 即时冲掉本次防抖。 + completionController.lineChanged(); } function fillSuggestion(item: CommandSuggestion) { @@ -3357,6 +3351,9 @@ function acceptGhostSuggestion() { ghostMatch.value = null; pendingTerminalInput += match.remainder; sendTerminalBytes(new TextEncoder().encode(match.remainder)); + // ghost 接受同样推进行缓冲(FIG wave-1 锚点):作废在途结构化补全结果 + // 并防抖重算(补全后的行可能命中引擎候选)。 + completionController.lineChanged(); // 接受后按新行重算:更长同前缀历史可继续 → 扩展(fish 同款行为)。 updateGhostSuggestion(); } @@ -3503,9 +3500,12 @@ function resetCommandMarker() { commandMarker.durationMs = null; commandMarker.cwd = ""; commandMarker.startedAt = null; - // 会话切换/断开:建议浮层与抑制门锁存一并复位(P1-1);ghost 门同步复位。 + // 会话切换/断开:建议浮层与抑制门锁存一并复位(P1-1);ghost 门同步复位; + // 结构化补全在途结果经 resetSession 作废(sessionId guard 另有兜底)。 closeSuggestions(); suggestionGuardState = createSuggestionGuardState(); + completionInputAllowed = false; + completionController.resetSession(); lastTerminalCommand.value = null; resetGhostSuggestion(); } @@ -11435,15 +11435,9 @@ async function initialize() { // 外观偏好的 CSS 部分(终端内边距变量)与宿主是否推送 appearance 无关, // 开机先落一次,否则用户设了内边距要等下次主题推送才生效。 applyTerminalPaddingVars(); - // 远端 fs 动态补全(review 第三批 14 首实现):数据源为 SFTP 面板已加载 - // 条目(零新增远端调用);面板未就绪/目录不一致时 provider 返回 null, - // hint 行保持 + Tab 透传 shell。 - registerDynamicCompletionProvider( - createRemoteFsProvider(() => { - if (!sftpPaneOpen.value) return null; - return { currentPath: currentPath.value, entries: entries.value }; - }), - ); + // FIG wave-1:legacy 动态 fs provider 随 legacy spec 目录整体退役; + // generator 动态候选在批次 2 经 completion/execute 目标机执行接线 + // (前端不直连 shell)。 if (api.appearance) applyAppearance(api.appearance); else if (isDbxPluginTheme(api.theme)) applyAppearance(themeToAppearance(api.theme)); // 宿主可能在 init 前先应答 host.getContext(如重推连接期间 init 被延迟): @@ -12090,13 +12084,12 @@ onBeforeUnmount(() => { @activate="(index) => (suggestionActiveIndex = index)" @fill="fillSuggestion" /> - + { const table: Record = { "completionMenu.title": "Command completion", - "completionMenu.levelSub": "Subcommands", - "completionMenu.levelFlag": "Flags", - "completionMenu.levelValue": "Values", - "completionMenu.acceptHint": "Tab/Enter fills · Esc closes", + "completionMenu.acceptHint": "Tab fills · Esc closes", }; return table[key] ?? key; }; -const rows: CompletionRow[] = [ - { kind: "sub", token: "checkout", space: true, label: "checkout", description: "Switch branches", score: 100 }, - { kind: "flag", token: "--branch", space: false, label: "--branch ", description: "Create a branch (-b)", score: 70 }, - { kind: "hint", token: "", space: false, label: "", description: "Dynamic value", score: 0 }, +function makeItem(id: string, overrides: Partial = {}): CompletionItem { + return { + id, + label: id, + description: `${id} description`, + kind: "subcommand", + score: 100, + source: "test", + edit: { text: `${id} `, replaceStart: 4, replaceEnd: 6 }, + ...overrides, + }; +} + +const items: CompletionItem[] = [ + makeItem("checkout", { label: "checkout", description: "Switch branches" }), + makeItem("--branch", { label: "--branch ", description: "Create a branch (-b)", kind: "option", score: 70 }), + makeItem("hint-branch", { label: "", description: "Dynamic value", kind: "hint", score: 0 }), ]; -function mountMenu(activeIndex = 0, level: "sub" | "flag" | "value" = "sub", anchor: SuggestionAnchor | null = { x: 40, y: 80, cellHeight: 18 }) { +function mountMenu(activeIndex = 0, anchor: SuggestionAnchor | null = { x: 40, y: 80, cellHeight: 18 }) { return mount(CompletionMenu, { - props: { rows, level, commandPath: ["git", "checkout"], activeIndex, anchor, t }, + props: { items, activeIndex, anchor, t }, }); } describe("CompletionMenu", () => { - it("renders the command breadcrumb and the localized level label", () => { - const wrapper = mountMenu(0, "flag"); - expect(wrapper.find(".completion-crumb").text()).toBe("git › checkout"); - expect(wrapper.find(".completion-level").text()).toBe("Flags"); + it("renders the localized listbox label and one option per item", () => { + const wrapper = mountMenu(0); expect(wrapper.find(".completion-menu").attributes("aria-label")).toBe("Command completion"); + expect(wrapper.findAll('[role="option"]')).toHaveLength(3); }); it("marks only the active row and exposes listbox option semantics", () => { const wrapper = mountMenu(1); const options = wrapper.findAll('[role="option"]'); - expect(options).toHaveLength(3); expect(options[1].classes()).toContain("active"); expect(options[0].classes()).not.toContain("active"); expect(options[1].attributes("aria-selected")).toBe("true"); }); - it("emits activate on hover and accept with the full row on click", async () => { + it("emits activate on hover and accept with the full CompletionItem on click", async () => { const wrapper = mountMenu(); await wrapper.findAll('[role="option"]')[2].trigger("mouseenter"); expect(wrapper.emitted("activate")?.[0]).toEqual([2]); await wrapper.findAll('[role="option"]')[0].trigger("click"); - expect(wrapper.emitted("accept")?.[0]).toEqual([rows[0]]); + // accept 回传完整候选(App 经 controller.accept 执行 item.edit,§24 映射)。 + expect(wrapper.emitted("accept")?.[0]).toEqual([items[0]]); }); it("weakens hint rows and falls back to the bottom dock without an anchor", () => { - const wrapper = mountMenu(2, "value", null); + const wrapper = mountMenu(2, null); const hintRow = wrapper.findAll('[role="option"]')[2]; expect(hintRow.classes()).toContain("hint"); expect(wrapper.find(".completion-menu").classes()).toContain("anchor-fallback"); @@ -65,8 +76,15 @@ describe("CompletionMenu", () => { it("positions the panel just below the cursor row when available", () => { // 锚点 y 为光标行顶(textarea rect 语义):top = 行顶 + 行高 + gap(issue #120) - const wrapper = mountMenu(0, "sub", { x: 40, y: 80, cellHeight: 18 }); + const wrapper = mountMenu(0, { x: 40, y: 80, cellHeight: 18 }); expect(wrapper.find(".completion-menu").attributes("style")).toContain("left: 46px"); expect(wrapper.find(".completion-menu").attributes("style")).toContain("top: 104px"); }); + + it("keys rows by the stable engine item id", () => { + // item.id 是引擎给出的稳定键:候选重排/重挂时不复用错误 DOM 状态。 + const wrapper = mountMenu(0); + const rows = wrapper.findAll('[role="option"]'); + expect(rows.map((row) => row.find(".completion-label").text())).toEqual(["checkout", "--branch ", ""]); + }); }); diff --git a/frontend/src/components/CompletionMenu.vue b/frontend/src/components/CompletionMenu.vue index 3a615571..24de986a 100644 --- a/frontend/src/components/CompletionMenu.vue +++ b/frontend/src/components/CompletionMenu.vue @@ -1,15 +1,16 @@ @@ -151,31 +137,6 @@ function rowIcon(kind: CompletionRow["kind"]) { bottom: 12px; } -.completion-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 4px 8px 2px; - border-bottom: 1px solid var(--border); -} - -.completion-crumb { - font-size: 11px; - opacity: 0.7; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.completion-level { - flex: none; - font-size: 10.5px; - letter-spacing: 0.04em; - text-transform: uppercase; - opacity: 0.6; -} - .completion-row { display: flex; align-items: center; diff --git a/frontend/src/components/SettingsDialog.vue b/frontend/src/components/SettingsDialog.vue index be79b992..15444c49 100644 --- a/frontend/src/components/SettingsDialog.vue +++ b/frontend/src/components/SettingsDialog.vue @@ -151,7 +151,7 @@ function clampStartupDelayInput(raw: string): number { if (!Number.isFinite(value) || value < 0) return STARTUP_DELAY_DEFAULT_MS; return Math.min(value, STARTUP_DELAY_MAX_MS); } -import { pluginStore } from "../lib/pluginStore"; +import { COMPLETION_ENGINE_KEY, loadCompletionEngine, pluginStore, sanitizeCompletionEngine, type CompletionEngineSetting } from "../lib/pluginStore"; /** 连接级 SFTP 文件名编码覆盖(M16):同启动命令的自治 RPC 读写 * (`sftp_name_encoding_overrides` 键按 connectionId 分桶)。控件缺省 @@ -266,17 +266,19 @@ void loadRdpExperimentalPreference(); void loadX11Preference(); -// 结构化补全开关(对标 Warp/fig,线 2):组件内自治读写 pluginStore -// (键 ssh-completion-spec,"false" = 关,默认开)——不走 props/emit, -// App 在浮层弹出前直读同一键,无需事件同步。 -const SPEC_COMPLETION_ENABLED_KEY = "ssh-completion-spec"; -const specCompletionEnabled = ref(true); +// 结构化补全引擎选择(FIG wave-1,契约 §2.3):组件内自治读写 pluginStore +// (键 ssh-completion-engine,fig-safe 默认 / fig / off)——不走 props/emit, +// App 每次调度前直读同一键,无需事件同步。off = 无结构化浮层(历史/ghost +// 不受影响);fig 与 fig-safe 批次 1 行为相同,差异自 generator 接线起。 +const completionEngine = ref(loadCompletionEngine()); -function loadSpecCompletionEnabled(): boolean { +function setCompletionEngine(next: string) { + const value = sanitizeCompletionEngine(next); + completionEngine.value = value; try { - return pluginStore.getItem(SPEC_COMPLETION_ENABLED_KEY) !== "false"; + pluginStore.setItem(COMPLETION_ENGINE_KEY, value); } catch { - return true; + // 存储不可用(无宿主桥且 localStorage 受限):仅当前会话生效。 } } @@ -295,17 +297,6 @@ function loadGhostEnabled(): boolean { } } -function setSpecCompletionEnabled(next: boolean) { - specCompletionEnabled.value = next; - try { - pluginStore.setItem(SPEC_COMPLETION_ENABLED_KEY, next ? "true" : "false"); - } catch { - // 存储不可用(无宿主桥且 localStorage 受限):仅当前会话生效。 - } -} - -specCompletionEnabled.value = loadSpecCompletionEnabled(); - function setGhostEnabled(next: boolean) { ghostEnabled.value = next; try { @@ -2028,11 +2019,18 @@ defineExpose({ consumeInlineEsc, setDownloadDirDraft, setDownloadUseDefaultDraft

{{ t("suggestions.settingsMaxCharsHint") }}

-