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 diff --git a/docs/COMPLETION.zh-CN.md b/docs/COMPLETION.zh-CN.md index ff8b67bd..c4e8914d 100644 --- a/docs/COMPLETION.zh-CN.md +++ b/docs/COMPLETION.zh-CN.md @@ -59,14 +59,10 @@ registerDynamicCompletionProvider( 目录一致才处理),不产生任何新增远端 I/O。任意目录的 `sftp/list` 缓存、 `git branch`、kubectl resource 等 provider 后续按同接口接入。 -## Fig / Amazon Q spec 导入 +## Fig / Amazon Q spec 导入(已退役) -```bash -node scripts/import-fig-specs.mjs ~/downloads/fig-git.json -# → frontend/src/lib/completions/specs/imported-git.ts -# 在 specs/index.ts 手动聚合后生效;导入结果定位为原型,review 后可裁剪。 -``` - -归一化支持静态子集(options 长短名、带值 flag、required positional→dynamic、 -两层子命令树);fig 的 generators/priority 等动态指令忽略(由 provider/ -Tab 透传承担)。 +`scripts/import-fig-specs.mjs` 与 `lib/completions/**` 手写 spec 原型已随 +FIG wave-1 最终架构退役:补全语义改由 vendored amazon-q parser + withfig +全量语料承担(`frontend/vendor/`、`frontend/src/lib/completion/fig/`), +同步/校验走 `pnpm --dir frontend fig:sync` / `fig:verify`,详见 +`docs/FIG_ROADMAP.zh-CN.md` 与 `docs/fig-specs-size-report.md`。 diff --git a/docs/FIG_ADJUDICATIONS.zh-CN.md b/docs/FIG_ADJUDICATIONS.zh-CN.md new file mode 100644 index 00000000..0d7445ad --- /dev/null +++ b/docs/FIG_ADJUDICATIONS.zh-CN.md @@ -0,0 +1,35 @@ +# FIG 协调者裁决日志 + +> 逐条记录 lane 报告偏差的裁决结果;冻结文件与契约的变更以此为准。 + +## 2026-09-28 批次 1 + +### 1. A′:冻结文件注释性改动(追认) + +`core/tokenize.ts` / `tokenize.spec.ts` 头注释原含 `lib/completions/spec.ts` 字面量, +与 A′ 的退役 grep 门禁冲突;A′ 仅改注释、零代码语义。**追认**,冻结语义不变。 + +### 2. A′:新增 `lib/completion/testing/fakeFigSource.ts`(追认并补录归属) + +授权来源为 LANE_A 细则 §2.1("本 lane 提供 Fake source(测试用)");契约 §4 文件 +清单补录该路径至 A′ 归属。dev 构建(`import.meta.env.DEV`)注入 Fake 支撑手动清单、 +生产为 pass-through source 的分层设计**批准**;C′ 真实 source 在集成分支于 App.vue +构造点一处替换。 + +### 3. A′:键入路径时序与退役键断言写法(知悉,无异议) + +常规键入路径以 `request("typing")` 同步冲刷防抖(保历史建议/ghost 基线时序,冻结 +source 为同步接口);spec 内退役键字面量以字符串拼接书写满足 grep 门禁。均为实现 +细节裁量,符合红线。 + +### 4. B:基线漂移说明(无需动作) + +B 分支基于 `5b63b0b8`(早于最终架构提交 `448e92d8`)。与基线的文件差异均为单侧 +改动(冻结文件/文档仅 base 侧变更),三方合并自动解决;B 对 `backend/src/ssh.rs` +的改动属 LANE_B 细则预批的最小只读查询例外,集成时核对。 + +### 5. C′:现场观察(在途) + +C′ 已成功 clone 双上游(网络可用):`/tmp/fig-sync-probe` 内 amazon-q(已 pin)与 +fig-autocomplete 全量语料,并在组装语料共享模块的编译产物;尚未向 worktree 落盘。 +继续等待其提交与报告。 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_ROADMAP.zh-CN.md b/docs/FIG_ROADMAP.zh-CN.md new file mode 100644 index 00000000..62f622b6 --- /dev/null +++ b/docs/FIG_ROADMAP.zh-CN.md @@ -0,0 +1,67 @@ +# FIG 补全引擎总体规划(最终架构直达版) + +> 2026-09-28 指令:**放弃历史包袱,按最终目标实施**。本文取代原 wave-1 过渡路线; +> 方案全文 `docs/FIG_AUTOCOMPLETE_INTEGRATION_PLAN.zh-CN.md`(§ 引用指该文档)。 +> 基线:`codex/ssh/fig-wave1-base`。 + +## 0. 方案定性(一句话) + +**集成 amazon-q-developer-cli 的 autocomplete parser(Fig 补全方案的开源引擎,MIT OR Apache-2.0)+ withfig/autocomplete 全量语料,配对成本插件的结构化补全引擎;Fig 的宿主形态(UI / figterm / 专有运行时)由本插件的 xterm overlay + Rust sidecar + `completion/execute` 替代。** + +spec 数据与 parser 引擎是配对资产,二者都要、不改其语义;被抛弃的只有 Fig 的宿主形态和本插件自己的过渡层。 + +## 1. 取消项(历史包袱,不再做) + +| 取消 | 原因 | +|---|---| +| 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. 批次 + +### 批次 1(当前并发) + +| Lane | 分支 | 交付 | 细则 | +|---|---|---|---| +| 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. 风险登记(更新) + +| 风险 | 缓解 | +|---|---| +| 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 接线 | + +## 5. 治理(不变) + +agent 不 push / 不 merge / 不安装;B 属命令执行面变更,PR 需人工 review;冻结文件变更由协调者统一裁决后同步全 lane。 diff --git a/docs/FIG_VERIFICATION.zh-CN.md b/docs/FIG_VERIFICATION.zh-CN.md new file mode 100644 index 00000000..a403f43b --- /dev/null +++ b/docs/FIG_VERIFICATION.zh-CN.md @@ -0,0 +1,88 @@ +# FIG 验证计划(最终架构版) + +> 环境统一:`export PATH="$HOME/.nvm/versions/node/v22.21.0/bin:$HOME/Library/pnpm:$HOME/.cargo/bin:$PATH"` + +## 1. Lane 交付门禁(完成前自查,全绿才算完) + +### Lane A'(frontend-core) + +```bash +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 # 必须无结果 +``` + +### Lane C'(fig-specs) + +```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 +python3 /scripts/smoke_completion.py # docker 容器可用时 +``` + +### 归属检查(全 lane) + +`git diff --stat codex/ssh/fig-wave1-base` 只落契约 §4 归属文件(B 的 ssh.rs 预批例外须列报告)。 + +## 2. 集成阶段(三 lane 合入,integrator/协调者执行) + +```bash +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 +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 # 容器可用时 + +# 退役门禁 +grep -rn "lib/completions\|ssh-completion-spec" frontend/src # 无结果 +git grep -l "import-fig-specs" # 无结果 +``` + +## 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 独立分支,任一失败可单独弃置。 +- 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 new file mode 100644 index 00000000..e6274bae --- /dev/null +++ b/docs/FIG_WAVE1_CONTRACT.zh-CN.md @@ -0,0 +1,59 @@ +# FIG 补全引擎实施契约(最终架构版) + +> 基线 `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. 冻结文件(只 import 不改;变更须协调者裁决) + +- `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. 总线决策 + +1. **revision 纪律**:异步结果须 `revision` + `sessionId`(+requestId)匹配,否则静默丢弃(§5.1/§30/§43)。 +2. **键盘所有权**(§21,`keyboard.ts` 纯函数 + 表驱动单测固化): + + | 菜单状态 | Enter | Tab | ↑↓ | Esc | + |---|---|---|---|---| + | 静态候选 | 放行 shell | accept | 移动 | 关闭 | + | 动态/generator 位置或 loading | 放行 shell | 放行 shell | 移动 | 关闭 | + | 关闭 | shell | shell | shell | — | + + 菜单显示 ≠ 键盘所有权;补全任何一层失败不得影响 PTY 输入链路。 +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 一致) + +- 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。 + +## 4. 文件归属(越界即冲突) + +| Lane | 拥有(新增/修改/删除) | +|---|---| +| 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` | + +## 5. 门禁 + +- 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 / 不安装。 + +## 6. 提交与移交 + +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 new file mode 100644 index 00000000..57a33d59 --- /dev/null +++ b/docs/FIG_WAVE1_LANE_A_FRONTEND_CORE.zh-CN.md @@ -0,0 +1,56 @@ +# Lane A 细则(最终架构版):fig 引擎接线 + legacy 退役 + +> 分支 `codex/ssh/fig-wave1-frontend-core`,基线 `codex/ssh/fig-wave1-base`。 +> 冻结接口(只 import):`core/types.ts`、`core/tokenize.ts`、`fig/source.ts`。 +> 旧版细则中的 legacySpecAdapter / golden parity 章节**作废**;其余签名与 App.vue +> 锚点继续有效。先读:契约(最终架构版)、ROADMAP、FIG_VERIFICATION。 + +## 1. 目标 / 非目标 + +目标: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 new file mode 100644 index 00000000..34f754b9 --- /dev/null +++ b/docs/FIG_WAVE1_LANE_B_COMPLETION_HOST.zh-CN.md @@ -0,0 +1,158 @@ +# 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` 是冻结镜像,两边字段必须逐字一致。 +> +> 2026-09-28 注:最终架构调整(vendor amazon-q parser + legacy 退役)与本 lane +> **正交**——`completion/execute` 正是最终架构的 generator 执行通道。本文继续 +> 有效,在途 agent 按原文执行,勿受其他 lane 文档修订影响。 + +## 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..5e1ea8dc --- /dev/null +++ b/docs/FIG_WAVE1_LANE_C_FIG_SPECS.zh-CN.md @@ -0,0 +1,64 @@ +# Lane C 细则(最终架构版):vendored parser + 全量语料 + source 实现 + +> 分支 `codex/ssh/fig-wave1-fig-specs`,基线 `codex/ssh/fig-wave1-base`。 +> 冻结接口:`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/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)。 diff --git a/docs/fig-specs-size-report.md b/docs/fig-specs-size-report.md new file mode 100644 index 00000000..c6cac063 --- /dev/null +++ b/docs/fig-specs-size-report.md @@ -0,0 +1,84 @@ +# FIG 语料体积报告(Lane C',批次 1 交付) + +> 快照:specs `withfig/autocomplete@aef52acf` / parser +> `aws/amazon-q-developer-cli@5c621df6`(见 +> `frontend/vendor/fig-specs/snapshot.json` 双 pin)。产物由 +> `scripts/sync_fig_specs.mjs` 生成,全量数据以 snapshot.json 为准。 + +## 结论(供批次 2 决策 D2') + +- 全量语料编译产物 **86.76 MB / 1467 specs**,远超契约预算初值 5 MB; + 以当前单文件 UI 约束(D2':spec bundled、不引入 chunk)**全量 bundled + 不可行**。 +- 批次 1 交付按确定性 allowlist 入包:**724 specs / 4.99 MB**(≤ 5 MB 总量 + 预算),覆盖全部 MOST_USED_SPECS(git/docker/ssh/kubectl/npm/aws…)。 +- app 实测:`pnpm build` 单文件 `ui/index.html` = **4.2 MB**(语料 minify 后 + 约 3 MB + 现有应用),构建/加载无异常。 +- 非入包 spec 的编译产物不落库(build/ 只提交 allowlist 命中的文件), + 其体积记录在 snapshot.json `sizes`,需要时重跑 `fig:sync` 即可复现。 + +## 预算(snapshot.budget;verify/CLI 可覆写) + +| 项 | 值 | 说明 | +|---|---|---| +| 语料总量 | 5 MB(契约初值,未调) | 入包 allowlist 实际 4.99 MB | +| 单 spec | **500 KB(由细则初值 300 KB 上调)** | 首测:git spec bundled 409 KB、aws 391 KB——300 KB 会把最常用命令裁掉;见 snapshot.json budget.source | + +## 裁剪规则(确定性,同 pin 幂等) + +1. 优先级 1:parser 常量 `MOST_USED_SPECS`(单一来源:vendored + `packages/autocomplete-parser/src/constants.ts`); +2. 优先级 2:其余 spec 按体积**升序**贪心填充(单位字节覆盖最多命令); +3. 约束:单 spec ≤ 500 KB 且累计 ≤ 5 MB。 + +## 全量体积分布(编译产物,降序 Top 15;全表见 snapshot.sizes) + +| spec | 体积 | 状态 | +|---|---|---| +| gcloud/compute | 4781 KB | 裁(超单 spec 上限) | +| az/2.53.0/network | 2126 KB | 裁 | +| aws/ec2 | 1616 KB | 裁 | +| aws/sagemaker | 889 KB | 裁 | +| az/2.53.0/storage | 856 KB | 裁 | +| aws/rds | 848 KB | 裁 | +| aws/connect | 666 KB | 裁 | +| az/2.53.0/iot | 659 KB | 裁 | +| aws/s3api | 643 KB | 裁 | +| gcloud/dataproc | 596 KB | 裁 | +| gcloud/container | 596 KB | 裁 | +| aws/iot | 565 KB | 裁 | +| mongocli | 525 KB | 裁 | +| az/2.53.0/sql | 511 KB | 裁 | +| aws/iam | 506 KB | 裁 | + +- 超单 spec 上限(>500 KB)共 **16 个**,全部为 aws/gcloud/az 的深层子命令 + spec——即使单独放宽总量也拿不进 5 MB 预算,Top-N 决策可直接跳过; +- 这些 spec 的父命令(aws/gcloud/az 根 spec)已入包,根级子命令表仍可补全, + 仅"进入对应子命令后的下一级"降级 pass-through。 + +## 裁剪建议序(若批次 2 需进一步压缩,按体积降序剔除) + +按 `snapshot.sizes` 降序逐个剔除直至回到目标预算,即 verify 输出的 +`trimOrder`;首位依次为:git(409KB)→aws(391KB)→kubectl→az→gcloud→flutter→ +docker→pnpm→curl→npx…(git/aws 体积大但属 MOST_USED,建议保留并优先压 +budget 之外的按需通道,即批次 2 的按需 spec 分发,而非静态 bundled)。 + +## skipped(编译失败清单,共 6 个) + +| spec | 原因 | +|---|---| +| copilot / pre-commit / serverless / sls | 外部依赖 `yaml` 未 vendored | +| deno / rush | 外部依赖 `strip-json-comments` 未 vendored | + +均为数据转换类第三方库(spec 在模块顶层解析 YAML/JSON 字面量),与补全 +语义无关;如批次 2 需要这 6 个 spec,再评估按 vendor 依赖闭包补入 +(fig:sync 的 `NPM_PACKAGES` 列表),当前不引入。 + +## 其他 + +- vendor 产物 `fig:sync` 全目录(845 文件)同 pin 二次运行哈希一致(幂等); + snapshot `generatedAt` 取 parser pin 的提交时间而非运行时刻。 +- 语料中 6 个 versioned spec(az/fig/heroku/shopify/infracost/@usermn/sdc) + 经 `@fig/autocomplete-helpers` 编译入包;其中 default export 为函数者 + (az/fig 等 6 个)由 `figCompletionSource.prewarm()` 异步解析,未完成前 + 该命令名按 pass-through 降级。 diff --git a/frontend/build.mjs b/frontend/build.mjs index 45195c54..7df44c17 100644 --- a/frontend/build.mjs +++ b/frontend/build.mjs @@ -24,7 +24,9 @@ await build({ emptyOutDir: true, cssCodeSplit: false, assetsInlineLimit: 10 * 1024 * 1024, - rollupOptions: { output: { inlineDynamicImports: true } }, + // rolldown(vite 8 内置):inlineDynamicImports 已弃用,codeSplitting: false + // 为官方等价项(单 bundle 内联全部 dynamic imports),产物结构不变。 + rollupOptions: { output: { codeSplitting: false } }, }, }); diff --git a/frontend/package.json b/frontend/package.json index a06b668f..32fb07de 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,10 @@ "scripts": { "build": "node build.mjs", "typecheck": "vue-tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "fig:sync": "node ../scripts/sync_fig_specs.mjs", + "fig:verify": "node ../scripts/verify_fig_specs.mjs", + "fig:test": "vitest run src/lib/completion" }, "dependencies": { "@codemirror/language": "^6.12.4", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5fe526c7..bf302324 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -152,12 +152,18 @@ 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, type CompletionGeneratorChannel } 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 { figCompletionSource } from "./lib/completion/fig/figCompletionSource"; +import { createEngineRunner } from "./lib/completion/worker/engineRunner"; +import { GeneratorScheduler } from "./lib/completion/fig/generatorScheduler"; 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 +173,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 +912,170 @@ 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 下拉 +// 自治写入,本处每次调度前直读(无缓存即时生效)。 +// 批次 2-1 接线:真实 figCompletionSource(vendored amazon-q parser + 全量 +// 语料)DEV/生产同源;声明式 generator 位经 GeneratorScheduler → hostClient +// (completion/execute,目标机执行)异步补齐,静态候选先行(两段渲染)。 +// fig-safe 与 fig 本批次 generator 行为相同;off 时 controller 不调度。 +// 类型不标注冻结接口:collectGenerators 是 impl 上的批次 2-1 第二通道。 +// 批次 2-2 接线:engine runner 包裹同一 source——支持 data-URL worker 的环境 +// 走 worker 线程(resolve 返回 Promise,controller 三重 guard 异步交付), +// 否则/崩溃两次后永久主线程直跑(§44),调用方无感。 +const completionSource = createEngineRunner({ createSource: () => figCompletionSource }); + +// 声明式 generator 调度:目标取当前会话(ssh 优先,其次本地;串口无可执行 +// 目标 → null 即不执行),cwd 用 OSC 7/633 跟踪值(terminalCwd)。 +const completionScheduler = new GeneratorScheduler({ + // E lane 裁决:runner 只代理冻结接口的 resolve;槽位提取轻量且 generator + // 执行面在目标机(completion/execute),collect 维持主线程直引单例。 + collect: (request) => figCompletionSource.collectGenerators(request), + target: () => { + const sshSessionId = session.value?.sessionId; + if (sshSessionId) return { kind: "ssh", sessionId: sshSessionId }; + const localSessionId = localSession.value?.sessionId; + if (localSessionId) return { kind: "local", sessionId: localSessionId }; + return null; + }, + cwd: () => (terminalCwd.value ? terminalCwd.value : null), +}); +const completionGeneratorChannel: CompletionGeneratorChannel = { + slots: (request) => completionScheduler.slots(request), + run: (slot) => completionScheduler.run(slot), +}; 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; - } -} +// generator 在途占位态(§31):true 仅表示「无静态候选、动态候选在途」的 +// loading 浮层(零条目占位行,Tab/Enter 放行 shell);静态候选照常先行。 +const completionLoading = ref(false); +// 结构化补全输入门:仅 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; + completionLoading.value = false; +} + +/** 响应落地:ready(rankItems 排序截断后非空)开浮层;loading(generator + * 在途且无静态候选)开占位浮层;pass-through 关。 */ +function handleCompletionResponse(response: CompletionResponse) { + if (response.state === "ready" && response.items.length) { + completionLoading.value = false; + openCompletionMenu(response); + } else if (response.state === "loading") { + completionLoading.value = true; + completionItems.value = []; + completionActiveIndex.value = 0; + completionAnchor.value = readTerminalSuggestionAnchor(); + completionOpen.value = true; + } else { + closeCompletionMenu(); + } } -function openCompletionMenu(match: SpecMatch) { - completionCommandPath.value = match.commandPath; - completionLevel.value = match.level; - completionRows.value = match.rows; +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; + // loading(generator 在途且无静态候选)时 Tab/Enter 放行 shell + // (keyboard.ts 规则表 §21:动态/generator 位置或 loading 一律透传); + // 静态候选已就位则保持静态键盘模式(active 项可 Tab 接受)。 + const state: CompletionKeyboardState = { + menuOpen: completionOpen.value, + hasItems: items.length > 0, + activeItemKind: items[completionActiveIndex.value]?.kind ?? null, + loading: completionLoading.value && items.length === 0, + }; + 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 兜底); +// generators 通道接声明式 generator 调度(两段渲染 + 三重 guard 在 controller)。 +const completionController = new CompletionController({ + source: completionSource, + sessionId: () => session.value?.sessionId ?? localSession.value?.sessionId ?? serialSession.value?.sessionId ?? "", + readLine: () => pendingTerminalInput, + enabled: () => loadCompletionEngine() !== "off" && completionInputAllowed, + generators: completionGeneratorChannel, + 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 +3041,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 +3059,9 @@ function closeSuggestions() { suggestionOpen.value = false; suggestionItems.value = []; suggestionActiveIndex.value = 0; - // 结构化补全浮层与历史建议浮层同一生命周期(Ctrl+C/回车/Esc 同步关闭)。 + // 结构化补全浮层与历史建议浮层同一生命周期(Ctrl+C/回车/Esc 同步关闭); + // dismiss 同步作废挂起调度与在途结果(revision 前进)。 + completionController.dismiss(); closeCompletionMenu(); } @@ -3065,6 +3088,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 +3098,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 +3107,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 +3125,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 +3267,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 +3389,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 +3538,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 +11473,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,16 +12122,18 @@ 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..af0e5612 100644 --- a/frontend/src/components/CompletionMenu.vue +++ b/frontend/src/components/CompletionMenu.vue @@ -1,15 +1,16 @@ @@ -151,31 +145,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; @@ -233,4 +202,32 @@ function rowIcon(kind: CompletionRow["kind"]) { height: 12px; opacity: 0.45; } + +/* generator 在途占位行(§31):非交互状态行,弱化展示。 */ +.completion-row.completion-loading { + cursor: default; +} + +.completion-loading .completion-description { + opacity: 0.5; +} + +.completion-loading-icon { + animation: completion-loading-spin 1.1s linear infinite; +} + +@keyframes completion-loading-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .completion-loading-icon { + animation: none; + } +} 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") }}

-